From 775584392363fcee5ae9b65a722361726e644fa7 Mon Sep 17 00:00:00 2001 From: Jedediah Smith Date: Sun, 29 Jan 2017 19:43:34 -0500 Subject: [PATCH] Initial public release --- .gitattributes | 9 + .gitignore | 34 + API/api/pom.xml | 95 ++ .../src/main/java/tc/oc/api/ApiManifest.java | 48 + .../tc/oc/api/annotations/ApiRequired.java | 12 + .../java/tc/oc/api/annotations/Serialize.java | 19 + .../tc/oc/api/config/ApiConfiguration.java | 5 + .../java/tc/oc/api/config/ApiConstants.java | 7 + .../tc/oc/api/connectable/Connectable.java | 19 + .../oc/api/connectable/ConnectableBinder.java | 10 + .../api/connectable/ConnectablesManifest.java | 14 + .../java/tc/oc/api/connectable/Connector.java | 66 + .../java/tc/oc/api/docs/AbstractModel.java | 41 + .../src/main/java/tc/oc/api/docs/Arena.java | 21 + .../tc/oc/api/docs/BasicDeletableModel.java | 26 + .../main/java/tc/oc/api/docs/BasicModel.java | 28 + .../src/main/java/tc/oc/api/docs/Death.java | 5 + .../src/main/java/tc/oc/api/docs/Entrant.java | 16 + .../src/main/java/tc/oc/api/docs/Game.java | 19 + .../main/java/tc/oc/api/docs/MapRating.java | 25 + .../main/java/tc/oc/api/docs/MatchState.java | 9 + .../main/java/tc/oc/api/docs/Objective.java | 60 + .../java/tc/oc/api/docs/Participation.java | 33 + .../main/java/tc/oc/api/docs/PlayerId.java | 25 + .../main/java/tc/oc/api/docs/Punishment.java | 5 + .../src/main/java/tc/oc/api/docs/Report.java | 5 + .../java/tc/oc/api/docs/SemanticVersion.java | 82 ++ .../src/main/java/tc/oc/api/docs/Server.java | 5 + .../src/main/java/tc/oc/api/docs/Session.java | 5 + .../java/tc/oc/api/docs/SimplePlayerId.java | 56 + .../java/tc/oc/api/docs/SimpleUserId.java | 46 + .../src/main/java/tc/oc/api/docs/Ticket.java | 21 + .../main/java/tc/oc/api/docs/Tournament.java | 43 + .../src/main/java/tc/oc/api/docs/Trophy.java | 19 + .../src/main/java/tc/oc/api/docs/User.java | 5 + .../src/main/java/tc/oc/api/docs/UserId.java | 21 + .../src/main/java/tc/oc/api/docs/Whisper.java | 7 + .../src/main/java/tc/oc/api/docs/team.java | 28 + .../tc/oc/api/docs/virtual/BasicDocument.java | 4 + .../tc/oc/api/docs/virtual/CompetitorDoc.java | 3 + .../java/tc/oc/api/docs/virtual/DeathDoc.java | 50 + .../oc/api/docs/virtual/DeletableModel.java | 12 + .../tc/oc/api/docs/virtual/DeployInfo.java | 23 + .../java/tc/oc/api/docs/virtual/Document.java | 28 + .../tc/oc/api/docs/virtual/EngagementDoc.java | 48 + .../api/docs/virtual/EngagementDocBase.java | 79 + .../java/tc/oc/api/docs/virtual/MapDoc.java | 62 + .../java/tc/oc/api/docs/virtual/MatchDoc.java | 86 ++ .../java/tc/oc/api/docs/virtual/Model.java | 9 + .../tc/oc/api/docs/virtual/PartialModel.java | 37 + .../tc/oc/api/docs/virtual/PunishmentDoc.java | 68 + .../tc/oc/api/docs/virtual/ReportDoc.java | 37 + .../tc/oc/api/docs/virtual/ServerDoc.java | 194 +++ .../tc/oc/api/docs/virtual/SessionDoc.java | 24 + .../java/tc/oc/api/docs/virtual/UserDoc.java | 113 ++ .../tc/oc/api/docs/virtual/WhisperDoc.java | 29 + .../java/tc/oc/api/document/Accessor.java | 84 ++ .../java/tc/oc/api/document/BaseAccessor.java | 116 ++ .../tc/oc/api/document/DocumentGenerator.java | 9 + .../java/tc/oc/api/document/DocumentMeta.java | 101 ++ .../tc/oc/api/document/DocumentRegistry.java | 271 ++++ .../oc/api/document/DocumentSerializer.java | 104 ++ .../tc/oc/api/document/DocumentsManifest.java | 19 + .../tc/oc/api/document/FieldAccessor.java | 35 + .../java/tc/oc/api/document/FieldGetter.java | 30 + .../java/tc/oc/api/document/FieldSetter.java | 40 + .../main/java/tc/oc/api/document/Getter.java | 18 + .../java/tc/oc/api/document/GetterMethod.java | 62 + .../api/document/ProxyDocumentGenerator.java | 134 ++ .../main/java/tc/oc/api/document/Setter.java | 26 + .../java/tc/oc/api/document/SetterMethod.java | 76 + .../engagement/EngagementModelManifest.java | 17 + .../oc/api/engagement/EngagementService.java | 12 + .../engagement/EngagementUpdateRequest.java | 19 + .../engagement/LocalEngagementService.java | 17 + .../tc/oc/api/exceptions/ApiException.java | 53 + .../tc/oc/api/exceptions/ApiNotConnected.java | 16 + .../java/tc/oc/api/exceptions/Conflict.java | 14 + .../java/tc/oc/api/exceptions/Forbidden.java | 14 + .../java/tc/oc/api/exceptions/NotFound.java | 23 + .../exceptions/SerializationException.java | 19 + .../oc/api/exceptions/UnknownMessageType.java | 20 + .../api/exceptions/UnmappedUserException.java | 18 + .../api/exceptions/UnprocessableEntity.java | 14 + .../main/java/tc/oc/api/games/ArenaStore.java | 41 + .../tc/oc/api/games/GameModelManifest.java | 35 + .../main/java/tc/oc/api/games/GameStore.java | 10 + .../tc/oc/api/games/NullTicketService.java | 23 + .../java/tc/oc/api/games/TicketService.java | 16 + .../java/tc/oc/api/games/TicketStore.java | 54 + .../main/java/tc/oc/api/http/HttpClient.java | 307 ++++ .../oc/api/http/HttpClientConfiguration.java | 38 + .../api/http/HttpClientConfigurationImpl.java | 50 + .../java/tc/oc/api/http/HttpManifest.java | 14 + .../main/java/tc/oc/api/http/HttpOption.java | 12 + .../main/java/tc/oc/api/http/QueryUri.java | 40 + .../java/tc/oc/api/maps/MapModelManifest.java | 18 + .../tc/oc/api/maps/MapRatingsRequest.java | 25 + .../tc/oc/api/maps/MapRatingsResponse.java | 12 + .../main/java/tc/oc/api/maps/MapService.java | 17 + .../oc/api/maps/MapUpdateMultiResponse.java | 26 + .../java/tc/oc/api/maps/NullMapService.java | 28 + .../tc/oc/api/match/DeathSearchRequest.java | 26 + .../tc/oc/api/match/MatchModelManifest.java | 31 + .../main/java/tc/oc/api/message/Message.java | 20 + .../java/tc/oc/api/message/MessageBinder.java | 27 + .../tc/oc/api/message/MessageHandler.java | 9 + .../tc/oc/api/message/MessageListener.java | 30 + .../java/tc/oc/api/message/MessageMeta.java | 25 + .../java/tc/oc/api/message/MessageQueue.java | 38 + .../tc/oc/api/message/MessageRegistry.java | 166 +++ .../tc/oc/api/message/MessagesManifest.java | 51 + .../api/message/NoSuchMessageException.java | 9 + .../tc/oc/api/message/NullMessageQueue.java | 24 + .../tc/oc/api/message/types/CycleRequest.java | 16 + .../oc/api/message/types/CycleResponse.java | 33 + .../api/message/types/FindMultiRequest.java | 22 + .../api/message/types/FindMultiResponse.java | 12 + .../tc/oc/api/message/types/FindRequest.java | 38 + .../tc/oc/api/message/types/ModelDelete.java | 9 + .../tc/oc/api/message/types/ModelMessage.java | 13 + .../tc/oc/api/message/types/ModelUpdate.java | 8 + .../api/message/types/PartialModelUpdate.java | 11 + .../java/tc/oc/api/message/types/Ping.java | 30 + .../oc/api/message/types/PlayGameRequest.java | 17 + .../message/types/PlayerTeleportRequest.java | 29 + .../java/tc/oc/api/message/types/Reply.java | 37 + .../message/types/ServerUpdateRequest.java | 17 + .../api/message/types/UpdateMultiRequest.java | 11 + .../message/types/UpdateMultiResponse.java | 67 + .../tc/oc/api/model/BatchUpdateRequest.java | 39 + .../java/tc/oc/api/model/BatchUpdater.java | 19 + .../tc/oc/api/model/BatchUpdaterFactory.java | 66 + .../tc/oc/api/model/HttpModelService.java | 92 ++ .../tc/oc/api/model/HttpQueryService.java | 80 ++ .../main/java/tc/oc/api/model/IdFactory.java | 12 + .../java/tc/oc/api/model/ModelBinder.java | 152 ++ .../java/tc/oc/api/model/ModelBinders.java | 43 + .../java/tc/oc/api/model/ModelDispatcher.java | 76 + .../java/tc/oc/api/model/ModelHandler.java | 17 + .../java/tc/oc/api/model/ModelListener.java | 9 + .../tc/oc/api/model/ModelListenerBinder.java | 32 + .../main/java/tc/oc/api/model/ModelMeta.java | 105 ++ .../main/java/tc/oc/api/model/ModelName.java | 11 + .../java/tc/oc/api/model/ModelRegistry.java | 55 + .../java/tc/oc/api/model/ModelService.java | 23 + .../main/java/tc/oc/api/model/ModelStore.java | 257 ++++ .../main/java/tc/oc/api/model/ModelSync.java | 21 + .../tc/oc/api/model/ModelTypeLiterals.java | 96 ++ .../java/tc/oc/api/model/ModelsManifest.java | 35 + .../tc/oc/api/model/NoSuchModelException.java | 9 + .../tc/oc/api/model/NullModelService.java | 45 + .../tc/oc/api/model/NullQueryService.java | 26 + .../java/tc/oc/api/model/QueryService.java | 38 + .../java/tc/oc/api/model/UpdateService.java | 46 + .../punishments/PunishmentModelManifest.java | 16 + .../punishments/PunishmentSearchRequest.java | 33 + .../main/java/tc/oc/api/queue/Consume.java | 41 + .../main/java/tc/oc/api/queue/Delivery.java | 39 + .../main/java/tc/oc/api/queue/Exchange.java | 125 ++ .../java/tc/oc/api/queue/MessageDefaults.java | 21 + .../main/java/tc/oc/api/queue/Metadata.java | 209 +++ .../java/tc/oc/api/queue/PrimaryQueue.java | 30 + .../main/java/tc/oc/api/queue/Publish.java | 60 + .../src/main/java/tc/oc/api/queue/Queue.java | 359 +++++ .../java/tc/oc/api/queue/QueueClient.java | 235 +++ .../api/queue/QueueClientConfiguration.java | 42 + .../queue/QueueClientConfigurationImpl.java | 65 + .../java/tc/oc/api/queue/QueueManifest.java | 36 + .../tc/oc/api/queue/QueueQueryService.java | 27 + .../java/tc/oc/api/queue/Transaction.java | 168 +++ .../oc/api/reports/ReportModelManifest.java | 16 + .../oc/api/reports/ReportSearchRequest.java | 62 + .../serialization/DurationTypeAdapter.java | 20 + .../tc/oc/api/serialization/GsonBinder.java | 66 + .../serialization/InetAddressTypeAdapter.java | 19 + .../api/serialization/InstantTypeAdapter.java | 40 + .../oc/api/serialization/JsonDebugReader.java | 133 ++ .../tc/oc/api/serialization/JsonUtils.java | 38 + .../LenientEnumSetTypeAdapter.java | 49 + .../serialization/NullableTypeAdapter.java | 38 + .../oc/api/serialization/PathTypeAdapter.java | 21 + .../serialization/PlayerIdTypeAdapter.java | 33 + .../java/tc/oc/api/serialization/Pretty.java | 16 + .../SemanticVersionTypeAdapter.java | 30 + .../serialization/SerializationManifest.java | 46 + .../serialization/StrictEnumTypeAdapter.java | 50 + .../serialization/TypeAdaptersManifest.java | 39 + .../api/serialization/UserIdTypeAdapter.java | 22 + .../oc/api/serialization/UuidTypeAdapter.java | 20 + .../oc/api/servers/BungeeMetricRequest.java | 16 + .../tc/oc/api/servers/NullServerService.java | 15 + .../java/tc/oc/api/servers/PingRequest.java | 5 + .../java/tc/oc/api/servers/PingResult.java | 7 + .../oc/api/servers/ServerModelManifest.java | 21 + .../oc/api/servers/ServerSearchRequest.java | 11 + .../java/tc/oc/api/servers/ServerService.java | 11 + .../java/tc/oc/api/servers/ServerStore.java | 77 + .../java/tc/oc/api/sessions/BadNickname.java | 10 + .../oc/api/sessions/NullSessionService.java | 41 + .../tc/oc/api/sessions/SessionChange.java | 13 + .../oc/api/sessions/SessionModelManifest.java | 19 + .../tc/oc/api/sessions/SessionService.java | 22 + .../oc/api/sessions/SessionStartRequest.java | 19 + .../oc/api/tourney/NullTournamentService.java | 32 + .../oc/api/tourney/RecordMatchResponse.java | 15 + .../java/tc/oc/api/tourney/TeamUtils.java | 9 + .../api/tourney/TournamentModelManifest.java | 25 + .../tc/oc/api/tourney/TournamentService.java | 31 + .../tc/oc/api/tourney/TournamentStore.java | 9 + .../oc/api/trophies/TrophyModelManifest.java | 18 + .../java/tc/oc/api/trophies/TrophyStore.java | 31 + .../tc/oc/api/users/ChangeClassRequest.java | 13 + .../tc/oc/api/users/ChangeSettingRequest.java | 14 + .../oc/api/users/CreditRaindropsRequest.java | 9 + .../java/tc/oc/api/users/LoginRequest.java | 36 + .../java/tc/oc/api/users/LoginResponse.java | 25 + .../java/tc/oc/api/users/LogoutRequest.java | 14 + .../java/tc/oc/api/users/NullUserService.java | 57 + .../tc/oc/api/users/PurchaseGizmoRequest.java | 11 + .../tc/oc/api/users/UserModelManifest.java | 19 + .../tc/oc/api/users/UserSearchRequest.java | 17 + .../tc/oc/api/users/UserSearchResponse.java | 27 + .../java/tc/oc/api/users/UserService.java | 37 + .../tc/oc/api/users/UserUpdateResponse.java | 26 + .../main/java/tc/oc/api/users/UserUtils.java | 10 + .../main/java/tc/oc/api/util/Permissions.java | 37 + .../src/main/java/tc/oc/api/util/UUIDs.java | 18 + .../oc/api/whispers/NullWhisperService.java | 17 + .../oc/api/whispers/WhisperModelManifest.java | 21 + .../tc/oc/api/whispers/WhisperService.java | 12 + API/api/src/main/resources/config.yml | 37 + API/api/src/test/java/tc/oc/ApiTest.java | 77 + .../test/java/tc/oc/document/ClassDoc.java | 16 + .../document/DocumentDeserializationTest.java | 68 + .../tc/oc/document/DocumentGeneratorTest.java | 127 ++ .../document/DocumentSerializationTest.java | 97 ++ .../oc/document/GenericFieldInterfaceDoc.java | 12 + .../tc/oc/document/GenericInterfaceDoc.java | 12 + .../java/tc/oc/document/InterfaceDoc.java | 10 + .../tc/oc/message/MessageRegistryTest.java | 34 + API/bukkit/pom.xml | 82 ++ .../tc/oc/api/bukkit/BukkitApiManifest.java | 49 + .../oc/api/bukkit/event/UserUpdateEvent.java | 32 + .../oc/api/bukkit/friends/OnlineFriends.java | 51 + .../oc/api/bukkit/users/BukkitUserStore.java | 77 + .../tc/oc/api/bukkit/users/OnlinePlayers.java | 5 + .../java/tc/oc/api/bukkit/users/Users.java | 47 + API/bukkit/src/main/resources/plugin.yml | 6 + API/bungee/pom.xml | 76 + .../tc/oc/api/bungee/BungeeApiManifest.java | 44 + .../oc/api/bungee/users/BungeeUserStore.java | 19 + .../tc/oc/api/bungee/users/OnlinePlayers.java | 5 + API/bungee/src/main/resources/plugin.yml | 5 + API/minecraft/pom.xml | 48 + .../api/minecraft/MinecraftApiManifest.java | 55 + .../tc/oc/api/minecraft/MinecraftService.java | 26 + .../api/minecraft/MinecraftServiceImpl.java | 178 +++ .../config/MinecraftApiConfiguration.java | 15 + .../config/MinecraftApiConfigurationImpl.java | 44 + .../minecraft/logging/LoggingCommands.java | 231 +++ .../logging/MinecraftLoggingManifest.java | 16 + .../logging/NotOurProblemRavenFilter.java | 36 + .../minecraft/logging/RavenServerTagger.java | 51 + .../api/minecraft/maps/LocalMapService.java | 48 + .../minecraft/maps/MinecraftMapsManifest.java | 13 + .../model/MinecraftModelsManifest.java | 24 + .../oc/api/minecraft/model/ModelCommands.java | 156 ++ .../queue/MinecraftQueueManifest.java | 16 + .../oc/api/minecraft/queue/QueueCommands.java | 88 ++ .../servers/LocalServerDocument.java | 285 ++++ .../servers/LocalServerReconfigureEvent.java | 28 + .../minecraft/servers/LocalServerService.java | 57 + .../servers/MinecraftServersManifest.java | 15 + .../servers/StartupServerDocument.java | 73 + .../sessions/LocalSessionFactory.java | 75 + .../sessions/LocalSessionService.java | 56 + .../sessions/MinecraftSessionsManifest.java | 15 + .../minecraft/users/LocalUserDocument.java | 132 ++ .../api/minecraft/users/LocalUserService.java | 151 ++ .../users/MinecraftUsersManifest.java | 21 + .../oc/api/minecraft/users/OnlinePlayers.java | 65 + .../tc/oc/api/minecraft/users/UserStore.java | 246 ++++ API/ocn/pom.xml | 49 + .../java/tc/oc/api/ocn/OCNApiManifest.java | 16 + .../tc/oc/api/ocn/OCNEngagementService.java | 21 + .../java/tc/oc/api/ocn/OCNMapService.java | 30 + .../java/tc/oc/api/ocn/OCNModelsManifest.java | 78 + .../java/tc/oc/api/ocn/OCNServerService.java | 51 + .../java/tc/oc/api/ocn/OCNSessionService.java | 60 + .../java/tc/oc/api/ocn/OCNTicketService.java | 28 + .../tc/oc/api/ocn/OCNTournamentService.java | 54 + .../java/tc/oc/api/ocn/OCNUserService.java | 113 ++ .../java/tc/oc/api/ocn/OCNWhisperService.java | 25 + API/ocn/src/main/resources/plugin.yml | 5 + API/pom.xml | 22 + Commons/bukkit/pom.xml | 108 ++ .../analytics/BukkitPlayerReporter.java | 21 + .../oc/bukkit/analytics/LatencyReporter.java | 41 + .../tc/oc/bukkit/analytics/TickReporter.java | 47 + .../commons/bukkit/CommonsBukkitManifest.java | 227 +++ .../bukkit/broadcast/BroadcastFormatter.java | 30 + .../bukkit/broadcast/BroadcastManifest.java | 35 + .../bukkit/broadcast/BroadcastParser.java | 59 + .../bukkit/broadcast/BroadcastScheduler.java | 156 ++ .../bukkit/broadcast/BroadcastSettings.java | 79 + .../broadcast/model/BroadcastPrefix.java | 5 + .../broadcast/model/BroadcastSchedule.java | 46 + .../bukkit/broadcast/model/BroadcastSet.java | 24 + .../commons/bukkit/channels/AdminChannel.java | 129 ++ .../bukkit/channels/AdminChatManifest.java | 18 + .../bukkit/chat/CachingNameRenderer.java | 56 + .../bukkit/chat/ComponentPaginator.java | 10 + .../oc/commons/bukkit/chat/FlairRenderer.java | 55 + .../commons/bukkit/chat/FullNameRenderer.java | 29 + .../oc/commons/bukkit/chat/LinkComponent.java | 94 ++ .../java/tc/oc/commons/bukkit/chat/Links.java | 76 + .../tc/oc/commons/bukkit/chat/NameFlag.java | 16 + .../oc/commons/bukkit/chat/NameRenderer.java | 7 + .../tc/oc/commons/bukkit/chat/NameStyle.java | 73 + .../tc/oc/commons/bukkit/chat/NameType.java | 58 + .../java/tc/oc/commons/bukkit/chat/Named.java | 7 + .../tc/oc/commons/bukkit/chat/Paginator.java | 115 ++ .../bukkit/chat/PartialNameRenderer.java | 20 + .../commons/bukkit/chat/PlayerComponent.java | 76 + .../bukkit/chat/PlayerComponentRenderer.java | 27 + .../bukkit/chat/StyledNameFunction.java | 17 + .../bukkit/chat/TemplateComponent.java | 37 + .../bukkit/chat/TextComponentRenderer.java | 14 + .../chat/TranslatableComponentRenderer.java | 43 + .../bukkit/chat/UserTextComponent.java | 70 + .../chat/UserTextComponentRenderer.java | 11 + .../tc/oc/commons/bukkit/chat/UserURI.java | 37 + .../commons/bukkit/commands/CommandUtils.java | 201 +++ .../bukkit/commands/PermissionCommands.java | 138 ++ .../commands/PrettyPaginatedResult.java | 46 + .../bukkit/commands/ServerCommands.java | 170 +++ .../commands/ServerVisibilityCommands.java | 137 ++ .../commons/bukkit/commands/SkinCommands.java | 76 + .../bukkit/commands/TraceCommands.java | 140 ++ .../commons/bukkit/commands/UserCommands.java | 125 ++ .../commons/bukkit/commands/UserFinder.java | 226 +++ .../bukkit/config/ExternalConfiguration.java | 83 ++ .../oc/commons/bukkit/debug/LeakListener.java | 50 + .../bukkit/event/AsyncUserLoginEvent.java | 47 + .../bukkit/event/ObserverKitApplyEvent.java | 24 + .../bukkit/event/PlayerServerChangeEvent.java | 41 + .../tc/oc/commons/bukkit/event/UserEvent.java | 10 + .../commons/bukkit/event/UserLoginEvent.java | 102 ++ .../event/WhitelistStateChangeEvent.java | 29 + .../commons/bukkit/format/GameFormatter.java | 235 +++ .../commons/bukkit/format/MiscFormatter.java | 32 + .../bukkit/format/ServerFormatter.java | 304 ++++ .../commons/bukkit/format/UserFormatter.java | 128 ++ .../commons/bukkit/freeze/FrozenPlayer.java | 11 + .../commons/bukkit/freeze/PlayerFreezer.java | 102 ++ .../oc/commons/bukkit/hologram/Hologram.java | 35 + .../commons/bukkit/hologram/HologramUtil.java | 151 ++ .../hologram/content/HologramAnimation.java | 113 ++ .../hologram/content/HologramContent.java | 6 + .../hologram/content/HologramFrame.java | 86 ++ .../bukkit/listeners/AppealAlertListener.java | 45 + .../listeners/InactivePlayerListener.java | 142 ++ .../bukkit/listeners/LocaleListener.java | 39 + .../bukkit/listeners/LoginListener.java | 252 ++++ .../listeners/PermissionGroupListener.java | 63 + .../localization/CommonsTranslations.java | 17 + .../localization/LocalizationManifest.java | 21 + .../localization/LocalizedDocument.java | 110 ++ .../localization/LocalizedMessageMap.java | 93 ++ .../bukkit/localization/MessageMapParser.java | 28 + .../bukkit/localization/MessageTemplate.java | 86 ++ .../bukkit/localization/PluginLocales.java | 19 + .../localization/PluginTranslations.java | 96 ++ .../bukkit/localization/Translations.java | 91 ++ .../commons/bukkit/logging/MapdevLogger.java | 37 + .../logging/MapdevSentryConfiguration.java | 24 + .../commons/bukkit/markup/MarkupParser.java | 158 ++ .../commons/bukkit/nick/ConsoleIdentity.java | 117 ++ .../oc/commons/bukkit/nick/Familiarity.java | 15 + .../tc/oc/commons/bukkit/nick/Identity.java | 107 ++ .../oc/commons/bukkit/nick/IdentityImpl.java | 160 +++ .../commons/bukkit/nick/IdentityProvider.java | 45 + .../bukkit/nick/IdentityProviderImpl.java | 203 +++ .../commons/bukkit/nick/NicknameCommands.java | 355 +++++ .../bukkit/nick/NicknameConfiguration.java | 26 + .../bukkit/nick/PlayerAppearanceChanger.java | 128 ++ .../bukkit/nick/PlayerAppearanceListener.java | 50 + .../nick/PlayerIdentityChangeEvent.java | 44 + .../oc/commons/bukkit/nick/PlayerOrder.java | 14 + .../commons/bukkit/nick/PlayerOrderCache.java | 90 ++ .../commons/bukkit/nick/UsernameRenderer.java | 103 ++ .../bukkit/punishment/PunishmentCommands.java | 231 +++ .../bukkit/punishment/PunishmentCreator.java | 74 + .../bukkit/punishment/PunishmentEnforcer.java | 165 +++ .../punishment/PunishmentFormatter.java | 112 ++ .../bukkit/punishment/PunishmentManifest.java | 20 + .../punishment/PunishmentMessageSetting.java | 31 + .../punishment/PunishmentPermissions.java | 32 + .../PlayerRecieveRaindropsEvent.java | 42 + .../bukkit/raindrops/RaindropCommands.java | 67 + .../bukkit/raindrops/RaindropConstants.java | 23 + .../bukkit/raindrops/RaindropManifest.java | 22 + .../bukkit/raindrops/RaindropResult.java | 13 + .../bukkit/raindrops/RaindropUtil.java | 170 +++ .../bukkit/report/ReportAnnouncer.java | 66 + .../commons/bukkit/report/ReportCommands.java | 195 +++ .../bukkit/report/ReportConfiguration.java | 33 + .../commons/bukkit/report/ReportCreator.java | 103 ++ .../bukkit/report/ReportFormatter.java | 76 + .../bukkit/report/ReportPermissions.java | 10 + .../bukkit/respack/ResourcePackCommands.java | 81 ++ .../bukkit/respack/ResourcePackListener.java | 161 +++ .../bukkit/respack/ResourcePackManager.java | 28 + .../bukkit/restart/RestartCommands.java | 96 ++ .../bukkit/sessions/SessionListener.java | 130 ++ .../bukkit/settings/RemoteTeleport.java | 20 + .../bukkit/settings/SettingBinder.java | 14 + .../settings/SettingCallbackBinder.java | 24 + .../settings/SettingManagerProvider.java | 32 + .../settings/SettingManagerProviderImpl.java | 167 +++ .../bukkit/settings/SettingManifest.java | 50 + .../commons/bukkit/tablist/BlankTabEntry.java | 20 + .../bukkit/tablist/DynamicTabEntry.java | 83 ++ .../bukkit/tablist/PlayerTabEntry.java | 105 ++ .../bukkit/tablist/SimpleTabEntry.java | 73 + .../bukkit/tablist/StaticTabEntry.java | 34 + .../oc/commons/bukkit/tablist/TabEntry.java | 80 ++ .../oc/commons/bukkit/tablist/TabManager.java | 159 +++ .../oc/commons/bukkit/tablist/TabRender.java | 152 ++ .../tc/oc/commons/bukkit/tablist/TabView.java | 384 +++++ .../teleport/FeaturedServerTracker.java | 101 ++ .../oc/commons/bukkit/teleport/Navigator.java | 398 ++++++ .../bukkit/teleport/NavigatorInterface.java | 371 +++++ .../bukkit/teleport/NavigatorManifest.java | 19 + .../bukkit/teleport/PlayerServerChanger.java | 101 ++ .../bukkit/teleport/TeleportCommands.java | 73 + .../bukkit/teleport/TeleportListener.java | 147 ++ .../commons/bukkit/teleport/Teleporter.java | 166 +++ .../oc/commons/bukkit/ticket/TicketBooth.java | 285 ++++ .../commons/bukkit/ticket/TicketCommands.java | 76 + .../commons/bukkit/ticket/TicketDisplay.java | 153 ++ .../commons/bukkit/ticket/TicketListener.java | 76 + .../commons/bukkit/trophies/TrophyCase.java | 87 ++ .../bukkit/trophies/TrophyCommands.java | 152 ++ .../commons/bukkit/trophies/TrophyEvent.java | 51 + .../bukkit/trophies/TrophyPermissions.java | 7 + .../bukkit/users/JoinMessageAnnouncer.java | 315 ++++ .../users/JoinMessageConfiguration.java | 47 + .../bukkit/users/JoinMessageManifest.java | 15 + .../bukkit/users/JoinMessageSetting.java | 40 + .../bukkit/users/PlayerSearchResponse.java | 20 + .../commons/bukkit/util/PermissionUtils.java | 19 + .../oc/commons/bukkit/util/PlayerStates.java | 30 + .../commons/bukkit/util/PlayerStatesImpl.java | 59 + .../util/SyncPlayerExecutorFactory.java | 75 + .../bukkit/whisper/WhisperCommands.java | 106 ++ .../bukkit/whisper/WhisperDispatcher.java | 197 +++ .../bukkit/whisper/WhisperFormatter.java | 145 ++ .../bukkit/whisper/WhisperManifest.java | 26 + .../commons/bukkit/whisper/WhisperSender.java | 8 + .../bukkit/whisper/WhisperSettings.java | 70 + .../commons/bukkit/whitelist/Whitelist.java | 106 ++ .../bukkit/whitelist/WhitelistCommands.java | 207 +++ .../java/tc/oc/parse/DocumentWatcher.java | 74 + Commons/bukkit/src/main/resources/config.yml | 35 + Commons/bukkit/src/main/resources/plugin.yml | 52 + .../java/tc/oc/commons/CommonsBukkitTest.java | 12 + .../commons/bukkit/geometry/CapsuleTest.java | 37 + .../bukkit/geometry/LineSegmentTest.java | 56 + .../bukkit/localization/LocalesTest.java | 51 + .../localization/TranslationKeyTests.java | 26 + .../bukkit/util/PotionClassificationTest.java | 125 ++ Commons/bungee/pom.xml | 73 + .../analytics/BungeePlayerReporter.java | 21 + .../analytics/PlayerTimeoutReporter.java | 47 + .../commons/bungee/CommonsBungeeManifest.java | 52 + .../bungee/commands/ServerCommands.java | 104 ++ .../bungee/listeners/LoginListener.java | 241 ++++ .../bungee/listeners/MetricListener.java | 37 + .../bungee/listeners/PingListener.java | 108 ++ .../bungee/listeners/PlayerServerRouter.java | 98 ++ .../bungee/listeners/TeleportListener.java | 68 + .../bungee/restart/RestartListener.java | 106 ++ .../commons/bungee/servers/LobbyTracker.java | 101 ++ .../commons/bungee/servers/ServerTracker.java | 103 ++ .../MojangSessionServiceCommands.java | 57 + .../sessions/MojangSessionServiceMonitor.java | 95 ++ .../commons/bungee/sessions/SessionState.java | 23 + Commons/bungee/src/main/resources/config.yml | 19 + Commons/bungee/src/main/resources/plugin.yml | 5 + Commons/core/build.xml | 16 + Commons/core/pom.xml | 92 ++ .../adminchat/AdminChatErrors.properties | 8 + .../adminchat/AdminChatMessages.properties | 3 + .../ChatModeratorErrors.properties | 1 + .../ChatModeratorMessages.properties | 2 + .../i18n/templates/commons/Commons.properties | 206 +++ .../templates/lobby/LobbyErrors.properties | 5 + .../templates/lobby/LobbyMessages.properties | 56 + .../lobby/LobbyMiscellaneous.properties | 9 + .../i18n/templates/lobby/LobbyUI.properties | 16 + .../i18n/templates/pgm/PGMDeath.properties | 191 +++ .../i18n/templates/pgm/PGMErrors.properties | 131 ++ .../i18n/templates/pgm/PGMMessages.properties | 226 +++ .../templates/pgm/PGMMiscellaneous.properties | 6 + .../main/i18n/templates/pgm/PGMUI.properties | 332 +++++ .../templates/projectares/PAErrors.properties | 56 + .../projectares/PAMessages.properties | 67 + .../templates/projectares/PAUI.properties | 56 + .../raindrops/RaindropsMessages.properties | 22 + .../i18n/templates/tourney/Tourney.properties | 21 + .../java/tc/oc/analytics/AnalyticsClient.java | 14 + .../tc/oc/analytics/AnalyticsManifest.java | 19 + .../src/main/java/tc/oc/analytics/Count.java | 32 + .../java/tc/oc/analytics/Distribution.java | 19 + .../java/tc/oc/analytics/DynamicTagger.java | 56 + .../src/main/java/tc/oc/analytics/Event.java | 72 + .../src/main/java/tc/oc/analytics/Gauge.java | 19 + .../src/main/java/tc/oc/analytics/Metric.java | 18 + .../java/tc/oc/analytics/MetricFactory.java | 10 + .../java/tc/oc/analytics/StageTagger.java | 22 + .../src/main/java/tc/oc/analytics/Tag.java | 40 + .../java/tc/oc/analytics/TagSetBuilder.java | 43 + .../src/main/java/tc/oc/analytics/Tagger.java | 7 + .../java/tc/oc/analytics/TaggerBinder.java | 18 + .../oc/analytics/datadog/DataDogClient.java | 135 ++ .../oc/analytics/datadog/DataDogConfig.java | 28 + .../oc/analytics/datadog/DataDogManifest.java | 34 + .../oc/commons/core/CommonsCoreManifest.java | 69 + .../commons/core/commands/DebugCommands.java | 117 ++ .../commons/core/format/GeneralFormatter.java | 23 + .../oc/commons/core/localization/Formats.java | 19 + .../commons/core/localization/LocaleMap.java | 42 + .../localization/LocalizedFileManager.java | 66 + .../core/localization/TranslationSet.java | 96 ++ .../core/restart/CancelRestartEvent.java | 8 + .../core/restart/RequestRestartEvent.java | 74 + .../core/restart/RestartConfiguration.java | 58 + .../commons/core/restart/RestartManager.java | 239 ++++ .../minecraft/analytics/AnalyticsFacet.java | 16 + .../analytics/MinecraftAnalyticsManifest.java | 12 + .../minecraft/analytics/PlayerReporter.java | 46 + .../oc/minecraft/analytics/ServerTagger.java | 51 + .../tc/oc/minecraft/server/ServerFilter.java | 94 ++ .../server/ServerFilterManifest.java | 19 + .../minecraft/server/ServerFilterParser.java | 48 + .../commons/core/util/IterableUtilsTest.java | 44 + .../oc/commons/core/util/ListUtilsTest.java | 21 + Commons/pom.xml | 45 + LICENSE.txt | 661 +++++++++ Lobby/pom.xml | 38 + .../main/java/tc/oc/lobby/bukkit/Lobby.java | 77 + .../java/tc/oc/lobby/bukkit/LobbyConfig.java | 80 ++ .../tc/oc/lobby/bukkit/LobbyManifest.java | 32 + .../tc/oc/lobby/bukkit/LobbyTranslations.java | 22 + .../java/tc/oc/lobby/bukkit/Settings.java | 69 + .../java/tc/oc/lobby/bukkit/SignUpdater.java | 346 +++++ .../main/java/tc/oc/lobby/bukkit/Utils.java | 75 + .../java/tc/oc/lobby/bukkit/gizmos/Gizmo.java | 123 ++ .../oc/lobby/bukkit/gizmos/GizmoConfig.java | 16 + .../tc/oc/lobby/bukkit/gizmos/GizmoUtils.java | 110 ++ .../tc/oc/lobby/bukkit/gizmos/Gizmos.java | 96 ++ .../bukkit/gizmos/chicken/ChickenGizmo.java | 154 ++ .../lobby/bukkit/gizmos/empty/EmptyGizmo.java | 27 + .../oc/lobby/bukkit/gizmos/gun/GunGizmo.java | 141 ++ .../bukkit/gizmos/launcher/LauncherGizmo.java | 64 + .../bukkit/gizmos/popper/PopperGizmo.java | 87 ++ .../oc/lobby/bukkit/gizmos/rocket/Rocket.java | 67 + .../bukkit/gizmos/rocket/RocketGizmo.java | 107 ++ .../bukkit/gizmos/rocket/RocketTask.java | 27 + .../bukkit/gizmos/rocket/RocketUtils.java | 66 + .../listeners/EnvironmentControlListener.java | 127 ++ .../bukkit/listeners/PlayerListener.java | 293 ++++ .../bukkit/listeners/PortalsListener.java | 44 + .../bukkit/listeners/RaindropsListener.java | 106 ++ .../tc/oc/lobby/bukkit/portals/Portal.java | 30 + .../lobby/bukkit/portals/PortalsConfig.java | 64 + Lobby/src/main/resources/config.yml | 9 + Lobby/src/main/resources/plugin.yml | 15 + PGM/pom.xml | 118 ++ PGM/src/main/java/tc/oc/pgm/Config.java | 158 ++ .../java/tc/oc/pgm/MapModulesManifest.java | 87 ++ PGM/src/main/java/tc/oc/pgm/PGM.java | 207 +++ PGM/src/main/java/tc/oc/pgm/PGMManifest.java | 97 ++ .../java/tc/oc/pgm/PGMModulesManifest.java | 66 + .../main/java/tc/oc/pgm/PGMTranslations.java | 30 + .../pgm/analytics/MatchAnalyticsManifest.java | 20 + .../oc/pgm/analytics/MatchPlayerReporter.java | 46 + .../java/tc/oc/pgm/analytics/MatchTagger.java | 28 + .../java/tc/oc/pgm/antigrief/AntiGrief.java | 29 + .../tc/oc/pgm/antigrief/CraftingProtect.java | 28 + .../tc/oc/pgm/antigrief/DefuseListener.java | 191 +++ .../tc/oc/pgm/api/EngagementMatchModule.java | 357 +++++ .../java/tc/oc/pgm/api/MatchDocument.java | 145 ++ .../pgm/api/MatchPublishingMatchModule.java | 127 ++ .../ParticipationPublishingMatchModule.java | 134 ++ .../java/tc/oc/pgm/blitz/BlitzConfig.java | 31 + .../tc/oc/pgm/blitz/BlitzMatchModule.java | 210 +++ .../tc/oc/pgm/blitz/BlitzMatchResult.java | 18 + .../java/tc/oc/pgm/blitz/BlitzModule.java | 85 ++ .../java/tc/oc/pgm/blitz/LifeManager.java | 45 + .../java/tc/oc/pgm/blockdrops/BlockDrops.java | 53 + .../pgm/blockdrops/BlockDropsMatchModule.java | 267 ++++ .../oc/pgm/blockdrops/BlockDropsModule.java | 96 ++ .../tc/oc/pgm/blockdrops/BlockDropsRule.java | 22 + .../oc/pgm/blockdrops/BlockDropsRuleSet.java | 149 ++ .../tc/oc/pgm/bossbar/BossBarContent.java | 18 + .../tc/oc/pgm/bossbar/BossBarMatchModule.java | 146 ++ .../java/tc/oc/pgm/bossbar/BossBarSource.java | 63 + .../java/tc/oc/pgm/broadcast/Broadcast.java | 73 + .../oc/pgm/broadcast/BroadcastManifest.java | 22 + .../tc/oc/pgm/broadcast/BroadcastParser.java | 79 + .../oc/pgm/broadcast/BroadcastScheduler.java | 67 + .../tc/oc/pgm/channels/ChannelCommands.java | 44 + .../oc/pgm/channels/ChannelMatchModule.java | 206 +++ .../oc/pgm/channels/FilteredPartyChannel.java | 21 + .../java/tc/oc/pgm/channels/PartyChannel.java | 8 + .../pgm/channels/UnfilteredPartyChannel.java | 21 + .../tc/oc/pgm/chat/MatchFlairRenderer.java | 49 + .../tc/oc/pgm/chat/MatchNameInvalidator.java | 63 + .../tc/oc/pgm/chat/MatchUsernameRenderer.java | 38 + .../java/tc/oc/pgm/classes/ClassCommands.java | 124 ++ .../java/tc/oc/pgm/classes/ClassManifest.java | 13 + .../tc/oc/pgm/classes/ClassMatchModule.java | 193 +++ .../java/tc/oc/pgm/classes/ClassModule.java | 164 +++ .../java/tc/oc/pgm/classes/PlayerClass.java | 91 ++ .../pgm/classes/PlayerClassChangeEvent.java | 51 + .../tc/oc/pgm/commands/AdminCommands.java | 226 +++ .../java/tc/oc/pgm/commands/CommandUtils.java | 115 ++ .../java/tc/oc/pgm/commands/MapCommands.java | 277 ++++ .../tc/oc/pgm/commands/MatchCommands.java | 31 + .../java/tc/oc/pgm/commands/PollCommands.java | 119 ++ .../pgm/commands/RotationControlCommands.java | 40 + .../oc/pgm/commands/RotationEditCommands.java | 107 ++ PGM/src/main/java/tc/oc/pgm/compose/All.java | 37 + PGM/src/main/java/tc/oc/pgm/compose/Any.java | 76 + .../tc/oc/pgm/compose/ComposableManifest.java | 35 + .../java/tc/oc/pgm/compose/Composition.java | 22 + .../tc/oc/pgm/compose/CompositionParser.java | 70 + .../main/java/tc/oc/pgm/compose/Maybe.java | 33 + PGM/src/main/java/tc/oc/pgm/compose/None.java | 23 + PGM/src/main/java/tc/oc/pgm/compose/Unit.java | 30 + .../tc/oc/pgm/controlpoint/ControlPoint.java | 500 +++++++ .../controlpoint/ControlPointAnnouncer.java | 46 + .../ControlPointBlockDisplay.java | 152 ++ .../controlpoint/ControlPointDefinition.java | 316 ++++ .../controlpoint/ControlPointManifest.java | 18 + .../controlpoint/ControlPointMatchModule.java | 53 + .../pgm/controlpoint/ControlPointParser.java | 116 ++ .../ControlPointPlayerTracker.java | 92 ++ .../ControlPointRootNodeFinder.java | 22 + .../events/CapturingTeamChangeEvent.java | 38 + .../events/CapturingTimeChangeEvent.java | 22 + .../events/ControlPointEvent.java | 18 + .../events/ControllerChangeEvent.java | 40 + .../oc/pgm/cooldown/CooldownPlayerFacet.java | 61 + PGM/src/main/java/tc/oc/pgm/core/Core.java | 192 +++ .../tc/oc/pgm/core/CoreBlockBreakEvent.java | 35 + .../java/tc/oc/pgm/core/CoreContribution.java | 20 + .../tc/oc/pgm/core/CoreConvertMonitor.java | 45 + .../main/java/tc/oc/pgm/core/CoreEvent.java | 17 + .../main/java/tc/oc/pgm/core/CoreFactory.java | 95 ++ .../java/tc/oc/pgm/core/CoreLeakEvent.java | 30 + .../java/tc/oc/pgm/core/CoreManifest.java | 18 + .../java/tc/oc/pgm/core/CoreMatchModule.java | 157 ++ .../main/java/tc/oc/pgm/core/CoreParser.java | 51 + .../java/tc/oc/pgm/countdowns/Countdown.java | 14 + .../countdowns/CountdownBossBarSource.java | 52 + .../oc/pgm/countdowns/CountdownContext.java | 294 ++++ .../tc/oc/pgm/countdowns/MatchCountdown.java | 125 ++ .../pgm/countdowns/MultiCountdownContext.java | 39 + .../countdowns/SingleCountdownContext.java | 79 + .../oc/pgm/crafting/CraftingMatchModule.java | 49 + .../tc/oc/pgm/crafting/CraftingModule.java | 185 +++ .../java/tc/oc/pgm/cycle/CycleCommands.java | 81 ++ .../java/tc/oc/pgm/cycle/CycleConfig.java | 55 + .../tc/oc/pgm/cycle/CycleMatchModule.java | 253 ++++ .../pgm/damage/DamageDisplayPlayerFacet.java | 136 ++ .../java/tc/oc/pgm/damage/DamageManifest.java | 26 + .../tc/oc/pgm/damage/DamageMatchModule.java | 271 ++++ .../java/tc/oc/pgm/damage/DamageModule.java | 48 + .../java/tc/oc/pgm/damage/DamageSettings.java | 26 + .../pgm/damage/DisableDamageMatchModule.java | 92 ++ .../tc/oc/pgm/damage/DisableDamageModule.java | 62 + .../tc/oc/pgm/damage/HitboxMatchModule.java | 32 + .../tc/oc/pgm/damage/HitboxPlayerFacet.java | 191 +++ .../tc/oc/pgm/death/DeathMessageBuilder.java | 445 ++++++ .../oc/pgm/death/DeathMessageMatchModule.java | 81 ++ .../tc/oc/pgm/death/DeathMessageSetting.java | 40 + .../death/HighlightDeathMessageSetting.java | 28 + .../java/tc/oc/pgm/debug/PGMLeakListener.java | 32 + .../tc/oc/pgm/destroyable/Destroyable.java | 635 +++++++++ .../pgm/destroyable/DestroyableCommands.java | 149 ++ .../destroyable/DestroyableContribution.java | 19 + .../DestroyableDestroyedEvent.java | 29 + .../oc/pgm/destroyable/DestroyableEvent.java | 32 + .../pgm/destroyable/DestroyableFactory.java | 128 ++ .../destroyable/DestroyableHealthChange.java | 78 + .../DestroyableHealthChangeEvent.java | 57 + .../pgm/destroyable/DestroyableManifest.java | 23 + .../destroyable/DestroyableMatchModule.java | 121 ++ .../oc/pgm/destroyable/DestroyableParser.java | 52 + .../development/MapDevelopmentCommands.java | 515 +++++++ .../oc/pgm/development/MapErrorTracker.java | 71 + .../tc/oc/pgm/doublejump/DoubleJumpKit.java | 37 + .../pgm/doublejump/DoubleJumpMatchModule.java | 146 ++ .../tc/oc/pgm/effect/BloodMatchModule.java | 53 + .../effect/LongRangeExplosionMatchModule.java | 40 + .../effect/ProjectileTrailMatchModule.java | 90 ++ .../java/tc/oc/pgm/eventrules/EventRule.java | 111 ++ .../oc/pgm/eventrules/EventRuleContext.java | 60 + .../pgm/eventrules/EventRuleMatchModule.java | 425 ++++++ .../tc/oc/pgm/eventrules/EventRuleModule.java | 59 + .../tc/oc/pgm/eventrules/EventRuleParser.java | 114 ++ .../tc/oc/pgm/eventrules/EventRuleScope.java | 33 + .../tc/oc/pgm/events/BlockTransformEvent.java | 185 +++ .../tc/oc/pgm/events/CompetitorAddEvent.java | 14 + .../oc/pgm/events/CompetitorRemoveEvent.java | 14 + .../tc/oc/pgm/events/ConfigLoadEvent.java | 30 + .../java/tc/oc/pgm/events/CycleEvent.java | 33 + .../tc/oc/pgm/events/FeatureChangeEvent.java | 29 + .../tc/oc/pgm/events/ItemTransferEvent.java | 110 ++ .../java/tc/oc/pgm/events/ListenerScope.java | 16 + .../tc/oc/pgm/events/MapArchiveEvent.java | 37 + .../tc/oc/pgm/events/MatchBeginEvent.java | 10 + .../java/tc/oc/pgm/events/MatchEndEvent.java | 10 + .../java/tc/oc/pgm/events/MatchEvent.java | 27 + .../java/tc/oc/pgm/events/MatchLoadEvent.java | 12 + .../tc/oc/pgm/events/MatchPlayerAddEvent.java | 42 + .../oc/pgm/events/MatchPlayerDamageEvent.java | 72 + .../oc/pgm/events/MatchPlayerDeathEvent.java | 161 +++ .../tc/oc/pgm/events/MatchPlayerEvent.java | 16 + .../oc/pgm/events/MatchPostCommitEvent.java | 19 + .../tc/oc/pgm/events/MatchPreCommitEvent.java | 19 + .../oc/pgm/events/MatchResultChangeEvent.java | 32 + .../oc/pgm/events/MatchScoreChangeEvent.java | 42 + .../oc/pgm/events/MatchStateChangeEvent.java | 41 + .../tc/oc/pgm/events/MatchUnloadEvent.java | 26 + .../tc/oc/pgm/events/MatchUserAddEvent.java | 21 + .../java/tc/oc/pgm/events/MatchUserEvent.java | 8 + .../oc/pgm/events/ObserverInteractEvent.java | 82 ++ .../ParticipantBlockTransformEvent.java | 35 + .../java/tc/oc/pgm/events/PartyAddEvent.java | 15 + .../java/tc/oc/pgm/events/PartyEvent.java | 17 + .../tc/oc/pgm/events/PartyRemoveEvent.java | 15 + .../tc/oc/pgm/events/PartyRenameEvent.java | 41 + .../pgm/events/PlayerBlockTransformEvent.java | 62 + .../oc/pgm/events/PlayerChangePartyEvent.java | 21 + .../pgm/events/PlayerItemTransferEvent.java | 100 ++ .../oc/pgm/events/PlayerJoinMatchEvent.java | 26 + .../oc/pgm/events/PlayerJoinPartyEvent.java | 29 + .../oc/pgm/events/PlayerLeaveMatchEvent.java | 16 + .../oc/pgm/events/PlayerLeavePartyEvent.java | 26 + .../pgm/events/PlayerParticipationEvent.java | 26 + .../events/PlayerParticipationStartEvent.java | 21 + .../events/PlayerParticipationStopEvent.java | 31 + .../oc/pgm/events/PlayerPartyChangeEvent.java | 31 + .../events/PlayerPartyChangeEventBase.java | 105 ++ .../tc/oc/pgm/events/PlayerResetEvent.java | 25 + .../tc/oc/pgm/events/SetNextMapEvent.java | 29 + .../oc/pgm/events/SingleMatchPlayerEvent.java | 27 + .../FallingBlocksMatchModule.java | 312 ++++ .../fallingblocks/FallingBlocksModule.java | 51 + .../pgm/fallingblocks/FallingBlocksRule.java | 67 + .../main/java/tc/oc/pgm/features/Feature.java | 20 + .../java/tc/oc/pgm/features/FeatureBase.java | 34 + .../tc/oc/pgm/features/FeatureBinder.java | 98 ++ .../tc/oc/pgm/features/FeatureDefinition.java | 157 ++ .../features/FeatureDefinitionContext.java | 840 +++++++++++ .../features/FeatureDefinitionException.java | 24 + .../pgm/features/FeatureDefinitionParser.java | 26 + .../tc/oc/pgm/features/FeatureFactory.java | 14 + .../java/tc/oc/pgm/features/FeatureInfo.java | 22 + .../tc/oc/pgm/features/FeatureManifest.java | 47 + .../tc/oc/pgm/features/FeatureParser.java | 443 ++++++ .../java/tc/oc/pgm/features/FeatureProxy.java | 8 + .../tc/oc/pgm/features/FeatureReference.java | 15 + .../oc/pgm/features/FeatureTypeLiterals.java | 40 + .../features/FeatureValidationContext.java | 35 + .../java/tc/oc/pgm/features/Features.java | 26 + .../tc/oc/pgm/features/GamemodeFeature.java | 15 + .../oc/pgm/features/LegacyFeatureParser.java | 39 + .../features/MagicMethodFeatureParser.java | 43 + .../oc/pgm/features/MatchFeatureContext.java | 102 ++ .../features/ReflectiveFeatureManifest.java | 51 + .../pgm/features/ReflectiveFeatureParser.java | 39 + .../oc/pgm/features/RootFeatureManifest.java | 110 ++ .../tc/oc/pgm/features/SluggedFeature.java | 5 + .../features/SluggedFeatureDefinition.java | 19 + .../tc/oc/pgm/ffa/FreeForAllCommands.java | 75 + .../tc/oc/pgm/ffa/FreeForAllMatchModule.java | 304 ++++ .../java/tc/oc/pgm/ffa/FreeForAllModule.java | 98 ++ .../java/tc/oc/pgm/ffa/FreeForAllOptions.java | 17 + PGM/src/main/java/tc/oc/pgm/ffa/Tribute.java | 234 +++ .../oc/pgm/ffa/events/MatchResizeEvent.java | 16 + .../main/java/tc/oc/pgm/filters/Filter.java | 214 +++ .../tc/oc/pgm/filters/FilterDispatcher.java | 36 + .../tc/oc/pgm/filters/FilterListener.java | 11 + .../tc/oc/pgm/filters/FilterManifest.java | 64 + .../tc/oc/pgm/filters/FilterMatchModule.java | 274 ++++ .../oc/pgm/filters/FilterTypeException.java | 19 + .../java/tc/oc/pgm/filters/Filterable.java | 61 + .../java/tc/oc/pgm/filters/Filterables.java | 32 + .../java/tc/oc/pgm/filters/ItemMatcher.java | 45 + .../oc/pgm/filters/matcher/CauseFilter.java | 171 +++ .../pgm/filters/matcher/QueryTypeFilter.java | 16 + .../oc/pgm/filters/matcher/StaticFilter.java | 50 + .../oc/pgm/filters/matcher/TypedFilter.java | 26 + .../pgm/filters/matcher/WeakTypedFilter.java | 30 + .../filters/matcher/block/MaterialFilter.java | 33 + .../matcher/block/StructuralLoadFilter.java | 29 + .../pgm/filters/matcher/block/VoidFilter.java | 26 + .../matcher/damage/AttackerFilter.java | 22 + .../filters/matcher/damage/DamagerFilter.java | 42 + .../matcher/damage/RelationFilter.java | 19 + .../filters/matcher/damage/VictimFilter.java | 20 + .../matcher/entity/EntityTypeFilter.java | 32 + .../matcher/entity/LegacyWorldFilter.java | 15 + .../matcher/entity/SpawnReasonFilter.java | 23 + .../matcher/match/FlagStateFilter.java | 43 + .../matcher/match/LegacyRandomFilter.java | 27 + .../matcher/match/MatchMutationFilter.java | 21 + .../matcher/match/MatchStateFilter.java | 42 + .../matcher/match/MonostableFilter.java | 215 +++ .../matcher/match/PlayerCountFilter.java | 95 ++ .../filters/matcher/match/RandomFilter.java | 22 + .../matcher/party/CompetitorFilter.java | 37 + .../pgm/filters/matcher/party/GoalFilter.java | 44 + .../pgm/filters/matcher/party/RankFilter.java | 28 + .../filters/matcher/party/ScoreFilter.java | 31 + .../pgm/filters/matcher/party/TeamFilter.java | 34 + .../matcher/player/AttributeFilter.java | 26 + .../filters/matcher/player/CanFlyFilter.java | 11 + .../matcher/player/CarryingFlagFilter.java | 33 + .../matcher/player/CarryingItemFilter.java | 15 + .../matcher/player/HoldingItemFilter.java | 19 + .../matcher/player/KillStreakFilter.java | 35 + .../matcher/player/ParticipatingFilter.java | 55 + .../matcher/player/PlayerClassFilter.java | 25 + .../filters/matcher/player/PoseFilter.java | 43 + .../matcher/player/SpawnedPlayerFilter.java | 28 + .../player/SpawnedPlayerItemFilter.java | 32 + .../matcher/player/WearingItemFilter.java | 15 + .../pgm/filters/operator/AggregateFilter.java | 46 + .../tc/oc/pgm/filters/operator/AllFilter.java | 43 + .../tc/oc/pgm/filters/operator/AnyFilter.java | 48 + .../oc/pgm/filters/operator/ChainFilter.java | 45 + .../filters/operator/FallthroughFilter.java | 61 + .../oc/pgm/filters/operator/FilterNode.java | 15 + .../pgm/filters/operator/InverseFilter.java | 33 + .../filters/operator/MultiFilterFunction.java | 36 + .../tc/oc/pgm/filters/operator/OneFilter.java | 46 + .../pgm/filters/operator/SameTeamFilter.java | 23 + .../operator/SingleFilterFunction.java | 49 + .../filters/operator/TeamFilterAdapter.java | 57 + .../filters/operator/TransformedFilter.java | 52 + .../parser/DynamicFilterValidation.java | 25 + .../parser/FilterDefinitionParser.java | 492 +++++++ .../oc/pgm/filters/parser/FilterParser.java | 103 ++ .../parser/LegacyFilterDefinitionParser.java | 68 + .../filters/parser/LegacyFilterParser.java | 64 + .../parser/RespondsToQueryValidation.java | 31 + .../oc/pgm/filters/query/BlockEventQuery.java | 39 + .../tc/oc/pgm/filters/query/BlockQuery.java | 84 ++ .../tc/oc/pgm/filters/query/DamageQuery.java | 38 + .../tc/oc/pgm/filters/query/EntityQuery.java | 34 + .../pgm/filters/query/EntitySpawnQuery.java | 34 + .../filters/query/ForwardingPlayerQuery.java | 51 + .../pgm/filters/query/IBlockEventQuery.java | 11 + .../tc/oc/pgm/filters/query/IBlockQuery.java | 13 + .../tc/oc/pgm/filters/query/IDamageQuery.java | 10 + .../pgm/filters/query/IEntityEventQuery.java | 10 + .../tc/oc/pgm/filters/query/IEntityQuery.java | 28 + .../pgm/filters/query/IEntitySpawnQuery.java | 15 + .../pgm/filters/query/IEntityTypeQuery.java | 13 + .../tc/oc/pgm/filters/query/IEventQuery.java | 13 + .../oc/pgm/filters/query/ILocationQuery.java | 18 + .../tc/oc/pgm/filters/query/IMatchQuery.java | 47 + .../oc/pgm/filters/query/IMaterialQuery.java | 13 + .../tc/oc/pgm/filters/query/IPartyQuery.java | 35 + .../filters/query/IPlayerBlockEventQuery.java | 11 + .../pgm/filters/query/IPlayerEventQuery.java | 11 + .../tc/oc/pgm/filters/query/IPlayerQuery.java | 46 + .../tc/oc/pgm/filters/query/IPoseQuery.java | 15 + .../java/tc/oc/pgm/filters/query/IQuery.java | 9 + .../oc/pgm/filters/query/ITransientQuery.java | 23 + .../oc/pgm/filters/query/MaterialQuery.java | 27 + .../filters/query/PlayerBlockEventQuery.java | 50 + .../pgm/filters/query/PlayerEventQuery.java | 25 + .../query/PlayerQueryWithLocation.java | 33 + .../filters/query/TransientPlayerQuery.java | 17 + .../oc/pgm/filters/query/TransientQuery.java | 17 + .../tc/oc/pgm/fireworks/FireworkUtil.java | 47 + .../tc/oc/pgm/fireworks/FireworksConfig.java | 37 + .../fireworks/ObjectivesFireworkListener.java | 99 ++ .../fireworks/PostMatchFireworkListener.java | 97 ++ PGM/src/main/java/tc/oc/pgm/flag/Flag.java | 547 +++++++ .../java/tc/oc/pgm/flag/FlagDefinition.java | 239 ++++ .../java/tc/oc/pgm/flag/FlagManifest.java | 11 + .../main/java/tc/oc/pgm/flag/FlagParser.java | 219 +++ PGM/src/main/java/tc/oc/pgm/flag/Net.java | 167 +++ PGM/src/main/java/tc/oc/pgm/flag/Post.java | 193 +++ .../oc/pgm/flag/event/FlagCaptureEvent.java | 51 + .../tc/oc/pgm/flag/event/FlagPickupEvent.java | 62 + .../pgm/flag/event/FlagStateChangeEvent.java | 47 + .../java/tc/oc/pgm/flag/state/BaseState.java | 205 +++ .../java/tc/oc/pgm/flag/state/Captured.java | 88 ++ .../java/tc/oc/pgm/flag/state/Carried.java | 382 +++++ .../java/tc/oc/pgm/flag/state/Completed.java | 40 + .../java/tc/oc/pgm/flag/state/Dropped.java | 99 ++ .../java/tc/oc/pgm/flag/state/Missing.java | 10 + .../java/tc/oc/pgm/flag/state/Respawning.java | 104 ++ .../java/tc/oc/pgm/flag/state/Returned.java | 63 + .../java/tc/oc/pgm/flag/state/Returning.java | 10 + .../java/tc/oc/pgm/flag/state/Spawned.java | 92 ++ .../main/java/tc/oc/pgm/flag/state/State.java | 29 + .../java/tc/oc/pgm/flag/state/Uncarried.java | 223 +++ .../main/java/tc/oc/pgm/freeze/Freeze.java | 159 +++ .../java/tc/oc/pgm/freeze/FreezeCommands.java | 50 + .../java/tc/oc/pgm/freeze/FreezeConfig.java | 28 + .../java/tc/oc/pgm/freeze/FreezeListener.java | 181 +++ .../java/tc/oc/pgm/gamerules/GameRule.java | 30 + .../pgm/gamerules/GameRulesMatchModule.java | 34 + .../tc/oc/pgm/gamerules/GameRulesModule.java | 65 + .../oc/pgm/ghostsquadron/GhostSquadron.java | 38 + .../GhostSquadronMatchModule.java | 408 ++++++ .../ghostsquadron/GhostSquadronModule.java | 48 + .../pgm/ghostsquadron/GhostSquadronTask.java | 71 + .../tc/oc/pgm/ghostsquadron/RevealTask.java | 49 + .../java/tc/oc/pgm/goals/Contribution.java | 23 + PGM/src/main/java/tc/oc/pgm/goals/Goal.java | 97 ++ .../java/tc/oc/pgm/goals/GoalCommands.java | 85 ++ .../java/tc/oc/pgm/goals/GoalComponent.java | 103 ++ .../java/tc/oc/pgm/goals/GoalDefinition.java | 35 + .../tc/oc/pgm/goals/GoalDefinitionImpl.java | 51 + .../java/tc/oc/pgm/goals/GoalMatchModule.java | 166 +++ .../main/java/tc/oc/pgm/goals/GoalModule.java | 31 + .../java/tc/oc/pgm/goals/GoalProgress.java | 162 +++ .../tc/oc/pgm/goals/GoalsMatchResult.java | 20 + .../oc/pgm/goals/GoalsVictoryCondition.java | 38 + .../java/tc/oc/pgm/goals/IncrementalGoal.java | 33 + .../java/tc/oc/pgm/goals/ModeChangeGoal.java | 16 + .../oc/pgm/goals/OwnableGoalDefinition.java | 21 + .../pgm/goals/OwnableGoalDefinitionImpl.java | 32 + .../main/java/tc/oc/pgm/goals/OwnedGoal.java | 48 + .../java/tc/oc/pgm/goals/ProximityGoal.java | 224 +++ .../oc/pgm/goals/ProximityGoalDefinition.java | 11 + .../goals/ProximityGoalDefinitionImpl.java | 32 + .../java/tc/oc/pgm/goals/ProximityMetric.java | 76 + .../main/java/tc/oc/pgm/goals/SimpleGoal.java | 130 ++ .../java/tc/oc/pgm/goals/TouchableGoal.java | 284 ++++ .../pgm/goals/events/GoalCompleteEvent.java | 56 + .../tc/oc/pgm/goals/events/GoalEvent.java | 30 + .../events/GoalProximityChangeEvent.java | 52 + .../goals/events/GoalStatusChangeEvent.java | 10 + .../oc/pgm/goals/events/GoalTouchEvent.java | 101 ++ .../tc/oc/pgm/hunger/HungerMatchModule.java | 25 + .../java/tc/oc/pgm/hunger/HungerModule.java | 36 + .../oc/pgm/inventory/InventoryCommands.java | 43 + .../inventory/ViewInventoryMatchModule.java | 422 ++++++ .../tc/oc/pgm/itemkeep/ItemKeepManifest.java | 20 + .../tc/oc/pgm/itemkeep/ItemKeepParser.java | 35 + .../oc/pgm/itemkeep/ItemKeepPlayerFacet.java | 88 ++ .../tc/oc/pgm/itemkeep/ItemKeepRules.java | 28 + .../java/tc/oc/pgm/itemmeta/ItemModifier.java | 33 + .../pgm/itemmeta/ItemModifyMatchModule.java | 78 + .../tc/oc/pgm/itemmeta/ItemModifyModule.java | 114 ++ .../java/tc/oc/pgm/itemmeta/ItemRule.java | 59 + .../main/java/tc/oc/pgm/join/JoinAllowed.java | 51 + .../java/tc/oc/pgm/join/JoinCommands.java | 65 + .../tc/oc/pgm/join/JoinConfiguration.java | 46 + .../main/java/tc/oc/pgm/join/JoinDenied.java | 94 ++ .../main/java/tc/oc/pgm/join/JoinHandler.java | 46 + .../java/tc/oc/pgm/join/JoinMatchModule.java | 338 +++++ .../main/java/tc/oc/pgm/join/JoinMethod.java | 8 + .../main/java/tc/oc/pgm/join/JoinQueued.java | 8 + .../main/java/tc/oc/pgm/join/JoinRequest.java | 39 + .../main/java/tc/oc/pgm/join/JoinResult.java | 89 ++ .../tc/oc/pgm/join/QueuedParticipants.java | 78 + .../java/tc/oc/pgm/killreward/KillReward.java | 18 + .../pgm/killreward/KillRewardMatchModule.java | 90 ++ .../oc/pgm/killreward/KillRewardModule.java | 71 + .../java/tc/oc/pgm/kits/AttributeKit.java | 38 + .../tc/oc/pgm/kits/AttributePlayerFacet.java | 58 + .../java/tc/oc/pgm/kits/ClearItemsKit.java | 25 + .../main/java/tc/oc/pgm/kits/ClearKit.java | 33 + .../java/tc/oc/pgm/kits/ClearKitBase.java | 26 + .../main/java/tc/oc/pgm/kits/DelayedKit.java | 20 + .../java/tc/oc/pgm/kits/EliminateKit.java | 13 + PGM/src/main/java/tc/oc/pgm/kits/FlyKit.java | 52 + .../main/java/tc/oc/pgm/kits/ForceKit.java | 31 + .../main/java/tc/oc/pgm/kits/FreeItemKit.java | 23 + .../main/java/tc/oc/pgm/kits/GameModeKit.java | 18 + .../java/tc/oc/pgm/kits/GlobalItemParser.java | 401 ++++++ .../java/tc/oc/pgm/kits/GrenadeListener.java | 60 + .../main/java/tc/oc/pgm/kits/HealthKit.java | 25 + .../main/java/tc/oc/pgm/kits/HitboxKit.java | 18 + .../main/java/tc/oc/pgm/kits/HungerKit.java | 30 + .../main/java/tc/oc/pgm/kits/ImpulseKit.java | 22 + PGM/src/main/java/tc/oc/pgm/kits/ItemKit.java | 26 + .../tc/oc/pgm/kits/ItemKitApplicator.java | 135 ++ .../main/java/tc/oc/pgm/kits/ItemParser.java | 36 + .../kits/ItemSharingAndLockingListener.java | 112 ++ PGM/src/main/java/tc/oc/pgm/kits/Kit.java | 63 + .../tc/oc/pgm/kits/KitDefinitionParser.java | 257 ++++ .../main/java/tc/oc/pgm/kits/KitManifest.java | 55 + PGM/src/main/java/tc/oc/pgm/kits/KitNode.java | 89 ++ .../main/java/tc/oc/pgm/kits/KitParser.java | 76 + .../java/tc/oc/pgm/kits/KitPlayerFacet.java | 21 + PGM/src/main/java/tc/oc/pgm/kits/KitRule.java | 64 + .../tc/oc/pgm/kits/KnockbackReductionKit.java | 26 + .../java/tc/oc/pgm/kits/MaxHealthKit.java | 30 + .../oc/pgm/kits/NaturalRegenerationKit.java | 22 + .../main/java/tc/oc/pgm/kits/PotionKit.java | 46 + .../tc/oc/pgm/kits/RemovableValidation.java | 26 + .../main/java/tc/oc/pgm/kits/RemoveKit.java | 37 + .../tc/oc/pgm/kits/ResetEnderPearlsKit.java | 20 + .../main/java/tc/oc/pgm/kits/SlotItemKit.java | 24 + .../java/tc/oc/pgm/kits/TeamSwitchKit.java | 20 + .../java/tc/oc/pgm/kits/WalkSpeedKit.java | 29 + .../main/java/tc/oc/pgm/kits/tag/Grenade.java | 35 + .../tc/oc/pgm/kits/tag/GrenadeItemTag.java | 37 + .../java/tc/oc/pgm/kits/tag/ItemTags.java | 11 + PGM/src/main/java/tc/oc/pgm/lane/Lane.java | 20 + .../java/tc/oc/pgm/lane/LaneManifest.java | 15 + .../java/tc/oc/pgm/lane/LaneMatchModule.java | 170 +++ .../pgm/listeners/BlockTransformListener.java | 500 +++++++ .../oc/pgm/listeners/FormattingListener.java | 92 ++ .../pgm/listeners/ItemTransferListener.java | 614 ++++++++ .../tc/oc/pgm/listeners/MatchAnnouncer.java | 186 +++ .../java/tc/oc/pgm/listeners/PGMListener.java | 137 ++ .../listeners/WorldProblemMatchModule.java | 117 ++ .../java/tc/oc/pgm/logging/MapFilter.java | 26 + .../java/tc/oc/pgm/logging/MapTagger.java | 52 + PGM/src/main/java/tc/oc/pgm/loot/Cache.java | 21 + .../java/tc/oc/pgm/loot/FillListener.java | 258 ++++ PGM/src/main/java/tc/oc/pgm/loot/Filler.java | 45 + PGM/src/main/java/tc/oc/pgm/loot/Loot.java | 13 + .../java/tc/oc/pgm/loot/LootManifest.java | 33 + .../main/java/tc/oc/pgm/map/Contributor.java | 105 ++ .../java/tc/oc/pgm/map/MapConfiguration.java | 14 + .../java/tc/oc/pgm/map/MapDefinition.java | 137 ++ .../main/java/tc/oc/pgm/map/MapDocument.java | 130 ++ .../tc/oc/pgm/map/MapFilePreprocessor.java | 202 +++ .../main/java/tc/oc/pgm/map/MapFolder.java | 124 ++ PGM/src/main/java/tc/oc/pgm/map/MapId.java | 96 ++ PGM/src/main/java/tc/oc/pgm/map/MapInfo.java | 192 +++ .../main/java/tc/oc/pgm/map/MapLibrary.java | 50 + .../java/tc/oc/pgm/map/MapLibraryImpl.java | 245 ++++ .../main/java/tc/oc/pgm/map/MapLoader.java | 17 + .../java/tc/oc/pgm/map/MapLoaderImpl.java | 91 ++ .../main/java/tc/oc/pgm/map/MapLogRecord.java | 79 + .../main/java/tc/oc/pgm/map/MapLogger.java | 41 + .../main/java/tc/oc/pgm/map/MapModule.java | 65 + .../java/tc/oc/pgm/map/MapModuleContext.java | 133 ++ .../java/tc/oc/pgm/map/MapModuleFactory.java | 11 + .../java/tc/oc/pgm/map/MapModuleManifest.java | 55 + .../java/tc/oc/pgm/map/MapModuleParser.java | 22 + .../tc/oc/pgm/map/MapNotFoundException.java | 20 + .../java/tc/oc/pgm/map/MapParserBinder.java | 10 + PGM/src/main/java/tc/oc/pgm/map/MapProto.java | 12 + .../java/tc/oc/pgm/map/MapRootParser.java | 20 + .../main/java/tc/oc/pgm/map/MapSource.java | 128 ++ .../tc/oc/pgm/map/MapmakerPlayerFacet.java | 48 + PGM/src/main/java/tc/oc/pgm/map/PGMMap.java | 109 ++ .../tc/oc/pgm/map/PGMMapConfiguration.java | 99 ++ .../java/tc/oc/pgm/map/PGMMapEnvironment.java | 84 ++ .../java/tc/oc/pgm/map/ParsingMethod.java | 7 + .../java/tc/oc/pgm/map/ParsingProvider.java | 19 + .../java/tc/oc/pgm/map/ProtoVersions.java | 47 + .../tc/oc/pgm/map/ProvisionAtParseTime.java | 50 + .../pgm/map/RootElementParsingProvider.java | 29 + .../pgm/map/StaticMethodMapModuleFactory.java | 23 + .../java/tc/oc/pgm/map/inject/MapBinders.java | 45 + .../oc/pgm/map/inject/MapInjectionScope.java | 50 + .../tc/oc/pgm/map/inject/MapManifest.java | 98 ++ .../java/tc/oc/pgm/map/inject/MapScoped.java | 12 + .../oc/pgm/mapratings/MapRatingsCommands.java | 54 + .../mapratings/MapRatingsConfiguration.java | 18 + .../pgm/mapratings/MapRatingsMatchModule.java | 434 ++++++ .../main/java/tc/oc/pgm/match/Competitor.java | 31 + .../pgm/match/FixtureMatchModuleFactory.java | 49 + PGM/src/main/java/tc/oc/pgm/match/Match.java | 657 +++++++++ .../java/tc/oc/pgm/match/MatchAudiences.java | 50 + .../java/tc/oc/pgm/match/MatchCounter.java | 16 + .../tc/oc/pgm/match/MatchEntityState.java | 107 ++ .../tc/oc/pgm/match/MatchEventRegistry.java | 174 +++ .../java/tc/oc/pgm/match/MatchExecutor.java | 45 + .../tc/oc/pgm/match/MatchFacetContext.java | 23 + .../pgm/match/MatchFacetContextManifest.java | 39 + .../java/tc/oc/pgm/match/MatchFinder.java | 115 ++ .../java/tc/oc/pgm/match/MatchFormatter.java | 127 ++ .../main/java/tc/oc/pgm/match/MatchImpl.java | 955 +++++++++++++ .../tc/oc/pgm/match/MatchInjectionScope.java | 50 + .../tc/oc/pgm/match/MatchListenerMeta.java | 23 + .../java/tc/oc/pgm/match/MatchLoader.java | 208 +++ .../java/tc/oc/pgm/match/MatchManager.java | 260 ++++ .../java/tc/oc/pgm/match/MatchManifest.java | 99 ++ .../java/tc/oc/pgm/match/MatchModule.java | 84 ++ .../tc/oc/pgm/match/MatchModuleContext.java | 30 + .../tc/oc/pgm/match/MatchModuleFactory.java | 31 + .../java/tc/oc/pgm/match/MatchPlayer.java | 641 +++++++++ .../oc/pgm/match/MatchPlayerEventRouter.java | 112 ++ .../tc/oc/pgm/match/MatchPlayerExecutor.java | 49 + .../tc/oc/pgm/match/MatchPlayerFacet.java | 23 + .../oc/pgm/match/MatchPlayerFacetBinder.java | 10 + .../pgm/match/MatchPlayerFacetManifest.java | 27 + .../tc/oc/pgm/match/MatchPlayerFinder.java | 104 ++ .../tc/oc/pgm/match/MatchPlayerManifest.java | 25 + .../tc/oc/pgm/match/MatchPlayerState.java | 117 ++ .../oc/pgm/match/MatchRealtimeScheduler.java | 115 ++ .../java/tc/oc/pgm/match/MatchScheduler.java | 107 ++ .../main/java/tc/oc/pgm/match/MatchScope.java | 5 + .../main/java/tc/oc/pgm/match/MatchState.java | 42 + .../tc/oc/pgm/match/MatchUserContext.java | 21 + .../java/tc/oc/pgm/match/MatchUserFacet.java | 30 + .../tc/oc/pgm/match/MatchUserFacetBinder.java | 10 + .../tc/oc/pgm/match/MatchUserManifest.java | 55 + .../main/java/tc/oc/pgm/match/Matches.java | 36 + .../tc/oc/pgm/match/MultiPlayerParty.java | 70 + .../main/java/tc/oc/pgm/match/Observers.java | 29 + .../java/tc/oc/pgm/match/ObservingParty.java | 111 ++ .../tc/oc/pgm/match/ParticipantState.java | 28 + .../main/java/tc/oc/pgm/match/Parties.java | 28 + PGM/src/main/java/tc/oc/pgm/match/Party.java | 170 +++ .../java/tc/oc/pgm/match/PlayerRelation.java | 40 + .../main/java/tc/oc/pgm/match/Repeatable.java | 24 + .../java/tc/oc/pgm/match/inject/ForMatch.java | 19 + .../tc/oc/pgm/match/inject/ForMatchUser.java | 17 + .../oc/pgm/match/inject/ForRunningMatch.java | 12 + .../tc/oc/pgm/match/inject/MatchBinders.java | 70 + .../inject/MatchModuleFactoryManifest.java | 46 + .../inject/MatchModuleFeatureManifest.java | 44 + .../inject/MatchModuleFixtureManifest.java | 43 + .../pgm/match/inject/MatchModuleManifest.java | 64 + .../tc/oc/pgm/match/inject/MatchScoped.java | 22 + .../java/tc/oc/pgm/modes/ObjectiveMode.java | 91 ++ .../oc/pgm/modes/ObjectiveModeCommands.java | 102 ++ .../tc/oc/pgm/modes/ObjectiveModeManager.java | 158 ++ .../oc/pgm/modes/ObjectiveModeManifest.java | 37 + .../oc/pgm/module/MatchModulesManifest.java | 74 + .../java/tc/oc/pgm/module/ModuleContext.java | 162 +++ .../module/ModuleDependencyTransformer.java | 79 + .../tc/oc/pgm/module/ModuleDescription.java | 34 + .../oc/pgm/module/ModuleExceptionHandler.java | 43 + .../tc/oc/pgm/module/ModuleLoadException.java | 44 + .../java/tc/oc/pgm/module/ModuleManifest.java | 219 +++ .../java/tc/oc/pgm/module/ModuleSource.java | 12 + .../tc/oc/pgm/module/ProvisionResult.java | 7 + .../tc/oc/pgm/module/ProvisionWrapper.java | 63 + .../pgm/module/UpstreamProvisionFailure.java | 13 + .../pgm/modules/ArrowRemovalMatchModule.java | 26 + .../DiscardPotionBottlesMatchModule.java | 26 + .../modules/DiscardPotionBottlesModule.java | 28 + .../pgm/modules/EventFilterMatchModule.java | 349 +++++ .../FriendlyFireRefundMatchModule.java | 36 + .../pgm/modules/FriendlyFireRefundModule.java | 31 + .../java/tc/oc/pgm/modules/InfoModule.java | 180 +++ .../oc/pgm/modules/InternalMatchModule.java | 34 + .../tc/oc/pgm/modules/InternalModule.java | 39 + .../pgm/modules/ItemDestroyMatchModule.java | 35 + .../tc/oc/pgm/modules/ItemDestroyModule.java | 55 + .../modules/MaxBuildHeightMatchModule.java | 32 + .../oc/pgm/modules/MaxBuildHeightModule.java | 40 + .../tc/oc/pgm/modules/MobsMatchModule.java | 47 + .../java/tc/oc/pgm/modules/MobsModule.java | 53 + .../ModifyBowProjectileMatchModule.java | 131 ++ .../modules/ModifyBowProjectileModule.java | 73 + .../oc/pgm/modules/MultiTradeMatchModule.java | 21 + .../modules/PlayableRegionMatchModule.java | 50 + .../oc/pgm/modules/PlayableRegionModule.java | 36 + .../tc/oc/pgm/modules/TimeLockModule.java | 33 + .../oc/pgm/modules/ToolRepairMatchModule.java | 59 + .../tc/oc/pgm/modules/ToolRepairModule.java | 49 + .../java/tc/oc/pgm/mutation/Mutation.java | 79 + .../tc/oc/pgm/mutation/MutationMapModule.java | 59 + .../oc/pgm/mutation/MutationMatchModule.java | 166 +++ .../tc/oc/pgm/mutation/MutationOptions.java | 21 + .../tc/oc/pgm/mutation/MutationQueue.java | 58 + .../mutation/command/MutationCommands.java | 185 +++ .../mutation/submodule/KitMutationModule.java | 28 + .../mutation/submodule/MutationModule.java | 68 + .../mutation/submodule/MutationModules.java | 110 ++ .../submodule/TargetableMutationModule.java | 46 + .../pgm/physics/AccelerationPlayerFacet.java | 77 + .../pgm/physics/DebugVelocityPlayerFacet.java | 48 + .../tc/oc/pgm/physics/KnockbackParser.java | 31 + .../oc/pgm/physics/KnockbackPlayerFacet.java | 78 + .../tc/oc/pgm/physics/KnockbackSettings.java | 46 + .../java/tc/oc/pgm/physics/PlayerForce.java | 20 + .../oc/pgm/physics/PlayerPhysicsManifest.java | 22 + .../java/tc/oc/pgm/physics/RelativeFlags.java | 64 + .../java/tc/oc/pgm/picker/PickerManifest.java | 13 + .../tc/oc/pgm/picker/PickerMatchModule.java | 653 +++++++++ .../java/tc/oc/pgm/picker/PickerSettings.java | 15 + .../main/java/tc/oc/pgm/pickup/Pickup.java | 156 ++ .../tc/oc/pgm/pickup/PickupDefinition.java | 126 ++ .../java/tc/oc/pgm/pickup/PickupModule.java | 61 + .../tc/oc/pgm/playerstats/StatSettings.java | 15 + .../tc/oc/pgm/playerstats/StatsManifest.java | 18 + .../oc/pgm/playerstats/StatsPlayerFacet.java | 87 ++ .../tc/oc/pgm/playerstats/StatsUserFacet.java | 75 + .../oc/pgm/points/AggregatePointProvider.java | 39 + .../java/tc/oc/pgm/points/AngleProvider.java | 16 + .../oc/pgm/points/DirectedPitchProvider.java | 29 + .../tc/oc/pgm/points/DirectedYawProvider.java | 27 + .../java/tc/oc/pgm/points/PointParser.java | 138 ++ .../java/tc/oc/pgm/points/PointProvider.java | 19 + .../pgm/points/PointProviderAttributes.java | 43 + .../oc/pgm/points/PointProviderLocation.java | 63 + .../tc/oc/pgm/points/RandomPointProvider.java | 43 + .../tc/oc/pgm/points/RegionPointProvider.java | 136 ++ .../pgm/points/SequentialPointProvider.java | 32 + .../tc/oc/pgm/points/SpreadPointProvider.java | 56 + .../tc/oc/pgm/points/StaticAngleProvider.java | 23 + PGM/src/main/java/tc/oc/pgm/polls/Poll.java | 101 ++ .../java/tc/oc/pgm/polls/PollEndEvent.java | 30 + .../java/tc/oc/pgm/polls/PollEndReason.java | 6 + .../main/java/tc/oc/pgm/polls/PollEvent.java | 19 + .../main/java/tc/oc/pgm/polls/PollKick.java | 26 + .../java/tc/oc/pgm/polls/PollListener.java | 33 + .../java/tc/oc/pgm/polls/PollManager.java | 58 + .../java/tc/oc/pgm/polls/PollNextMap.java | 29 + .../java/tc/oc/pgm/polls/PollStartEvent.java | 23 + .../tc/oc/pgm/portals/DoubleTransform.java | 72 + .../tc/oc/pgm/portals/InvertibleOperator.java | 10 + .../main/java/tc/oc/pgm/portals/Portal.java | 92 ++ .../tc/oc/pgm/portals/PortalExitRegion.java | 27 + .../java/tc/oc/pgm/portals/PortalModule.java | 169 +++ .../tc/oc/pgm/portals/PortalPlayerFacet.java | 32 + .../tc/oc/pgm/portals/PortalTransform.java | 198 +++ .../tc/oc/pgm/projectile/ClickAction.java | 7 + .../oc/pgm/projectile/EntityLaunchEvent.java | 51 + .../pgm/projectile/ProjectileDefinition.java | 111 ++ .../pgm/projectile/ProjectileMatchModule.java | 75 + .../oc/pgm/projectile/ProjectileModule.java | 49 + .../pgm/projectile/ProjectilePlayerFacet.java | 114 ++ .../tc/oc/pgm/projectile/Projectiles.java | 48 + .../tc/oc/pgm/proximity/ProximityAlarm.java | 125 ++ .../proximity/ProximityAlarmDefinition.java | 15 + .../proximity/ProximityAlarmMatchModule.java | 36 + .../pgm/proximity/ProximityAlarmModule.java | 68 + PGM/src/main/java/tc/oc/pgm/quota/Quota.java | 12 + .../java/tc/oc/pgm/quota/QuotaConfig.java | 104 ++ .../tc/oc/pgm/quota/QuotaMatchModule.java | 149 ++ .../java/tc/oc/pgm/rage/RageMatchModule.java | 31 + .../main/java/tc/oc/pgm/rage/RageModule.java | 55 + .../tc/oc/pgm/raindrops/RaindropListener.java | 312 ++++ .../tc/oc/pgm/raindrops/RaindropManifest.java | 13 + .../pgm/regions/BlockBoundedValidation.java | 16 + .../java/tc/oc/pgm/regions/BlockRegion.java | 44 + .../java/tc/oc/pgm/regions/Complement.java | 46 + .../tc/oc/pgm/regions/CompoundRegion.java | 39 + .../java/tc/oc/pgm/regions/CuboidRegion.java | 61 + .../tc/oc/pgm/regions/CuboidValidation.java | 16 + .../tc/oc/pgm/regions/CylindricalRegion.java | 68 + .../java/tc/oc/pgm/regions/EmptyRegion.java | 28 + .../tc/oc/pgm/regions/EverywhereRegion.java | 26 + .../tc/oc/pgm/regions/FiniteBlockRegion.java | 132 ++ .../tc/oc/pgm/regions/HalfspaceRegion.java | 24 + .../java/tc/oc/pgm/regions/Intersection.java | 59 + .../tc/oc/pgm/regions/MirroredRegion.java | 34 + .../tc/oc/pgm/regions/NegativeRegion.java | 51 + .../java/tc/oc/pgm/regions/PointRegion.java | 44 + .../pgm/regions/RandomPointsValidation.java | 16 + .../main/java/tc/oc/pgm/regions/Region.java | 195 +++ .../pgm/regions/RegionDefinitionParser.java | 198 +++ .../tc/oc/pgm/regions/RegionManifest.java | 26 + .../java/tc/oc/pgm/regions/RegionParser.java | 99 ++ .../main/java/tc/oc/pgm/regions/Regions.java | 13 + .../java/tc/oc/pgm/regions/SectorRegion.java | 35 + .../java/tc/oc/pgm/regions/SphereRegion.java | 46 + .../tc/oc/pgm/regions/TransformedRegion.java | 82 ++ .../tc/oc/pgm/regions/TranslatedRegion.java | 35 + .../main/java/tc/oc/pgm/regions/Union.java | 62 + .../java/tc/oc/pgm/renewable/BlockImage.java | 127 ++ .../tc/oc/pgm/renewable/BlockRenewEvent.java | 19 + .../java/tc/oc/pgm/renewable/Renewable.java | 282 ++++ .../oc/pgm/renewable/RenewableDefinition.java | 49 + .../pgm/renewable/RenewableMatchModule.java | 27 + .../tc/oc/pgm/renewable/RenewableModule.java | 111 ++ .../pgm/respack/ResourcePackMatchModule.java | 25 + .../pgm/restart/AutoRestartConfiguration.java | 29 + .../tc/oc/pgm/restart/RestartListener.java | 251 ++++ .../rotation/AbstractRotationProvider.java | 60 + .../oc/pgm/rotation/AppendTransformation.java | 44 + .../oc/pgm/rotation/FileRotationProvider.java | 151 ++ .../rotation/FileRotationProviderFactory.java | 48 + .../oc/pgm/rotation/InsertTransformation.java | 55 + .../pgm/rotation/RemoveAllTransformation.java | 50 + .../rotation/RemoveIndexTransformation.java | 48 + .../tc/oc/pgm/rotation/RotationManager.java | 169 +++ .../tc/oc/pgm/rotation/RotationProvider.java | 39 + .../oc/pgm/rotation/RotationProviderInfo.java | 30 + .../tc/oc/pgm/rotation/RotationState.java | 93 ++ .../pgm/rotation/RotationTransformation.java | 18 + .../java/tc/oc/pgm/score/ScoreBoxFactory.java | 159 +++ .../java/tc/oc/pgm/score/ScoreConfig.java | 15 + .../tc/oc/pgm/score/ScoreMatchModule.java | 102 ++ .../tc/oc/pgm/score/ScoreMatchResult.java | 19 + .../java/tc/oc/pgm/score/ScoreModule.java | 144 ++ .../oc/pgm/score/ScoreVictoryCondition.java | 29 + .../oc/pgm/scoreboard/ScoreboardManifest.java | 15 + .../pgm/scoreboard/ScoreboardMatchModule.java | 247 ++++ .../oc/pgm/scoreboard/ScoreboardSettings.java | 15 + .../oc/pgm/scoreboard/SidebarMatchModule.java | 528 +++++++ .../tc/oc/pgm/scoreboard/SidebarModule.java | 48 + .../tc/oc/pgm/settings/ObserverSetting.java | 33 + .../tc/oc/pgm/settings/ObserversCallback.java | 24 + .../java/tc/oc/pgm/settings/Settings.java | 47 + .../main/java/tc/oc/pgm/shield/ShieldKit.java | 29 + .../tc/oc/pgm/shield/ShieldMatchModule.java | 69 + .../tc/oc/pgm/shield/ShieldParameters.java | 17 + .../tc/oc/pgm/shield/ShieldPlayerModule.java | 119 ++ .../skillreq/SkillRequirementMatchModule.java | 50 + .../oc/pgm/snapshot/SnapshotMatchModule.java | 83 ++ .../tc/oc/pgm/spawns/ObserverToolFactory.java | 96 ++ .../java/tc/oc/pgm/spawns/RespawnOptions.java | 31 + PGM/src/main/java/tc/oc/pgm/spawns/Spawn.java | 69 + .../tc/oc/pgm/spawns/SpawnAttributes.java | 75 + .../tc/oc/pgm/spawns/SpawnMatchModule.java | 338 +++++ .../java/tc/oc/pgm/spawns/SpawnModule.java | 148 ++ .../java/tc/oc/pgm/spawns/SpawnParser.java | 128 ++ .../pgm/spawns/events/DeathKitApplyEvent.java | 20 + .../events/ParticipantDespawnEvent.java | 41 + .../events/ParticipantReleaseEvent.java | 25 + .../spawns/events/ParticipantSpawnEvent.java | 24 + .../pgm/spawns/events/PlayerSpawnEvent.java | 31 + .../java/tc/oc/pgm/spawns/states/Alive.java | 211 +++ .../java/tc/oc/pgm/spawns/states/Dead.java | 153 ++ .../java/tc/oc/pgm/spawns/states/Joining.java | 39 + .../tc/oc/pgm/spawns/states/Observing.java | 129 ++ .../oc/pgm/spawns/states/Participating.java | 33 + .../tc/oc/pgm/spawns/states/Spawning.java | 90 ++ .../java/tc/oc/pgm/spawns/states/State.java | 117 ++ .../tc/oc/pgm/stamina/PlayerStaminaState.java | 311 ++++ .../tc/oc/pgm/stamina/StaminaMatchModule.java | 108 ++ .../java/tc/oc/pgm/stamina/StaminaModule.java | 104 ++ .../tc/oc/pgm/stamina/StaminaOptions.java | 33 + .../pgm/stamina/mutators/SimpleMutator.java | 42 + .../pgm/stamina/mutators/StaminaMutator.java | 13 + .../pgm/stamina/symptoms/ArcherySymptom.java | 27 + .../oc/pgm/stamina/symptoms/MeleeSymptom.java | 24 + .../pgm/stamina/symptoms/PotionSymptom.java | 15 + .../pgm/stamina/symptoms/StaminaSymptom.java | 22 + .../java/tc/oc/pgm/start/HuddleCountdown.java | 99 ++ .../tc/oc/pgm/start/PreMatchCountdown.java | 45 + .../java/tc/oc/pgm/start/StartCommands.java | 89 ++ .../java/tc/oc/pgm/start/StartConfig.java | 33 + .../java/tc/oc/pgm/start/StartCountdown.java | 123 ++ .../tc/oc/pgm/start/StartMatchModule.java | 295 ++++ .../java/tc/oc/pgm/start/UnreadyReason.java | 21 + .../pgm/stats/DeathPublishingMatchModule.java | 239 ++++ .../stats/ObjectivePublishingMatchModule.java | 163 +++ .../oc/pgm/stats/StatisticsConfiguration.java | 29 + .../java/tc/oc/pgm/structure/Dynamic.java | 8 + .../oc/pgm/structure/DynamicDefinition.java | 122 ++ .../tc/oc/pgm/structure/DynamicScheduler.java | 60 + .../java/tc/oc/pgm/structure/Structure.java | 27 + .../oc/pgm/structure/StructureDefinition.java | 130 ++ .../oc/pgm/structure/StructureManifest.java | 19 + .../tc/oc/pgm/structure/StructureParser.java | 82 ++ .../tc/oc/pgm/tablist/FreeForAllTabEntry.java | 36 + .../java/tc/oc/pgm/tablist/MapTabEntry.java | 51 + .../oc/pgm/tablist/MatchFooterTabEntry.java | 78 + .../tc/oc/pgm/tablist/MatchPlayerOrder.java | 26 + .../tc/oc/pgm/tablist/MatchTabManager.java | 192 +++ .../java/tc/oc/pgm/tablist/MatchTabView.java | 263 ++++ .../java/tc/oc/pgm/tablist/TeamOrder.java | 36 + .../java/tc/oc/pgm/tablist/TeamTabEntry.java | 35 + .../main/java/tc/oc/pgm/teams/JoinTeam.java | 12 + PGM/src/main/java/tc/oc/pgm/teams/Team.java | 588 ++++++++ .../tc/oc/pgm/teams/TeamCommandUtils.java | 54 + .../java/tc/oc/pgm/teams/TeamCommands.java | 198 +++ .../tc/oc/pgm/teams/TeamConfiguration.java | 37 + .../java/tc/oc/pgm/teams/TeamFactory.java | 144 ++ .../java/tc/oc/pgm/teams/TeamManifest.java | 44 + .../java/tc/oc/pgm/teams/TeamMatchModule.java | 565 ++++++++ .../main/java/tc/oc/pgm/teams/TeamModule.java | 84 ++ .../main/java/tc/oc/pgm/teams/TeamParser.java | 34 + .../main/java/tc/oc/pgm/teams/TeamResult.java | 34 + PGM/src/main/java/tc/oc/pgm/teams/Teams.java | 23 + .../oc/pgm/teams/events/TeamResizeEvent.java | 34 + .../teams/events/TeamRespawnsChangeEvent.java | 42 + .../oc/pgm/terrain/BlockPhysicsListener.java | 39 + .../DisableKeepSpawnInMemoryListener.java | 22 + .../tc/oc/pgm/terrain/TerrainManifest.java | 29 + .../tc/oc/pgm/terrain/TerrainOptions.java | 35 + .../java/tc/oc/pgm/terrain/TerrainParser.java | 55 + .../tc/oc/pgm/terrain/WorldConfigurator.java | 14 + .../pgm/terrain/WorldConfiguratorBinder.java | 10 + .../java/tc/oc/pgm/terrain/WorldManager.java | 30 + .../tc/oc/pgm/terrain/WorldManagerImpl.java | 138 ++ .../main/java/tc/oc/pgm/time/TickClock.java | 10 + .../main/java/tc/oc/pgm/time/TickTime.java | 34 + .../java/tc/oc/pgm/time/WorldTickClock.java | 37 + .../java/tc/oc/pgm/timelimit/TimeLimit.java | 105 ++ .../oc/pgm/timelimit/TimeLimitCommands.java | 115 ++ .../oc/pgm/timelimit/TimeLimitCountdown.java | 129 ++ .../oc/pgm/timelimit/TimeLimitDefinition.java | 38 + .../pgm/timelimit/TimeLimitMatchModule.java | 57 + .../tc/oc/pgm/timelimit/TimeLimitModule.java | 99 ++ .../tc/oc/pgm/tnt/InstantTNTPlaceEvent.java | 26 + .../main/java/tc/oc/pgm/tnt/TNTManifest.java | 29 + .../java/tc/oc/pgm/tnt/TNTMatchModule.java | 162 +++ .../main/java/tc/oc/pgm/tnt/TNTParser.java | 66 + .../java/tc/oc/pgm/tnt/TNTProperties.java | 37 + .../tnt/license/LicenseAccessPlayerFacet.java | 133 ++ .../tc/oc/pgm/tnt/license/LicenseBroker.java | 204 +++ .../oc/pgm/tnt/license/LicenseCommands.java | 70 + .../pgm/tnt/license/LicenseConfiguration.java | 28 + .../tc/oc/pgm/tnt/license/LicenseMonitor.java | 26 + .../tnt/license/LicenseMonitorUserFacet.java | 108 ++ .../pgm/tnt/license/LicenseRevokeEvent.java | 21 + .../java/tc/oc/pgm/tracker/BlockResolver.java | 22 + .../tc/oc/pgm/tracker/EntityResolver.java | 22 + .../java/tc/oc/pgm/tracker/EventResolver.java | 42 + .../tc/oc/pgm/tracker/MasterResolver.java | 72 + .../tc/oc/pgm/tracker/ProjectileResolver.java | 12 + .../tc/oc/pgm/tracker/TrackerManifest.java | 86 ++ .../tc/oc/pgm/tracker/damage/AnvilInfo.java | 16 + .../tc/oc/pgm/tracker/damage/BlockInfo.java | 49 + .../tc/oc/pgm/tracker/damage/CauseInfo.java | 7 + .../tc/oc/pgm/tracker/damage/DamageInfo.java | 19 + .../oc/pgm/tracker/damage/DispenserInfo.java | 9 + .../tc/oc/pgm/tracker/damage/EntityInfo.java | 50 + .../oc/pgm/tracker/damage/ExplosionInfo.java | 44 + .../tc/oc/pgm/tracker/damage/FallInfo.java | 12 + .../tc/oc/pgm/tracker/damage/FallState.java | 135 ++ .../pgm/tracker/damage/FallingBlockInfo.java | 44 + .../tc/oc/pgm/tracker/damage/FireInfo.java | 38 + .../pgm/tracker/damage/GenericDamageInfo.java | 43 + .../pgm/tracker/damage/GenericFallInfo.java | 49 + .../pgm/tracker/damage/GenericPotionInfo.java | 45 + .../tc/oc/pgm/tracker/damage/ItemInfo.java | 55 + .../tc/oc/pgm/tracker/damage/MeleeInfo.java | 5 + .../tc/oc/pgm/tracker/damage/MobInfo.java | 30 + .../oc/pgm/tracker/damage/NullDamageInfo.java | 17 + .../tc/oc/pgm/tracker/damage/OwnerInfo.java | 9 + .../oc/pgm/tracker/damage/OwnerInfoBase.java | 25 + .../oc/pgm/tracker/damage/PhysicalInfo.java | 9 + .../tc/oc/pgm/tracker/damage/PlayerInfo.java | 55 + .../tc/oc/pgm/tracker/damage/PotionInfo.java | 9 + .../oc/pgm/tracker/damage/ProjectileInfo.java | 74 + .../tc/oc/pgm/tracker/damage/RangedInfo.java | 21 + .../tc/oc/pgm/tracker/damage/SpleefInfo.java | 38 + .../tc/oc/pgm/tracker/damage/TNTInfo.java | 20 + .../pgm/tracker/damage/ThrownPotionInfo.java | 46 + .../tc/oc/pgm/tracker/damage/TrackerInfo.java | 5 + .../pgm/tracker/event/PlayerSpleefEvent.java | 46 + .../pgm/tracker/resolvers/DamageResolver.java | 12 + .../resolvers/ExplosionDamageResolver.java | 23 + .../resolvers/FallingBlockDamageResolver.java | 18 + .../resolvers/GenericDamageResolver.java | 21 + .../resolvers/PotionDamageResolver.java | 33 + .../pgm/tracker/trackers/AbstractTracker.java | 58 + .../oc/pgm/tracker/trackers/AnvilTracker.java | 36 + .../oc/pgm/tracker/trackers/BlockTracker.java | 163 +++ .../tracker/trackers/CombatLogTracker.java | 284 ++++ .../oc/pgm/tracker/trackers/DeathTracker.java | 80 ++ .../tracker/trackers/DispenserTracker.java | 20 + .../pgm/tracker/trackers/EntityTracker.java | 97 ++ .../oc/pgm/tracker/trackers/FallTracker.java | 310 ++++ .../oc/pgm/tracker/trackers/FireTracker.java | 109 ++ .../pgm/tracker/trackers/OwnedMobTracker.java | 75 + .../tracker/trackers/ProjectileTracker.java | 57 + .../pgm/tracker/trackers/SpleefTracker.java | 126 ++ .../oc/pgm/tracker/trackers/TNTTracker.java | 71 + .../java/tc/oc/pgm/tutorial/Tutorial.java | 66 + .../tc/oc/pgm/tutorial/TutorialManifest.java | 18 + .../tc/oc/pgm/tutorial/TutorialParser.java | 89 ++ .../oc/pgm/tutorial/TutorialPlayerFacet.java | 132 ++ .../tc/oc/pgm/tutorial/TutorialStage.java | 113 ++ .../tc/oc/pgm/utils/AllMaterialMatcher.java | 39 + .../tc/oc/pgm/utils/BlockMaterialMatcher.java | 46 + .../oc/pgm/utils/CompoundMaterialMatcher.java | 67 + .../java/tc/oc/pgm/utils/EntityUtils.java | 63 + .../main/java/tc/oc/pgm/utils/Locations.java | 45 + .../java/tc/oc/pgm/utils/MatchPlayers.java | 15 + .../java/tc/oc/pgm/utils/MaterialMatcher.java | 25 + .../java/tc/oc/pgm/utils/MaterialPattern.java | 122 ++ .../java/tc/oc/pgm/utils/MethodParser.java | 9 + .../java/tc/oc/pgm/utils/MethodParserMap.java | 156 ++ .../tc/oc/pgm/utils/NoMaterialMatcher.java | 36 + .../java/tc/oc/pgm/utils/NumericModifier.java | 53 + .../tc/oc/pgm/utils/RollingAverageFilter.java | 75 + .../main/java/tc/oc/pgm/utils/Strings.java | 13 + .../java/tc/oc/pgm/utils/WorldTickRandom.java | 36 + .../main/java/tc/oc/pgm/utils/XMLUtils.java | 1270 +++++++++++++++++ .../pgm/victory/AbstractVictoryCondition.java | 24 + .../tc/oc/pgm/victory/CompetitorResult.java | 32 + .../java/tc/oc/pgm/victory/DefaultResult.java | 20 + .../victory/ImmediateVictoryCondition.java | 18 + .../java/tc/oc/pgm/victory/MatchResult.java | 23 + .../oc/pgm/victory/RankingsChangeEvent.java | 29 + .../java/tc/oc/pgm/victory/TieResult.java | 26 + .../tc/oc/pgm/victory/VictoryCalculator.java | 161 +++ .../tc/oc/pgm/victory/VictoryCondition.java | 34 + .../tc/oc/pgm/victory/VictoryMatchModule.java | 151 ++ .../oc/pgm/victory/VictoryResultParser.java | 55 + .../java/tc/oc/pgm/wool/MonumentWool.java | 169 +++ .../tc/oc/pgm/wool/MonumentWoolFactory.java | 187 +++ .../tc/oc/pgm/wool/PlayerWoolPlaceEvent.java | 42 + .../java/tc/oc/pgm/wool/WoolManifest.java | 18 + .../java/tc/oc/pgm/wool/WoolMatchModule.java | 222 +++ .../main/java/tc/oc/pgm/wool/WoolParser.java | 54 + .../tc/oc/pgm/worldborder/WorldBorder.java | 72 + .../worldborder/WorldBorderMatchModule.java | 175 +++ .../oc/pgm/worldborder/WorldBorderModule.java | 71 + .../java/tc/oc/pgm/xml/BoundedElement.java | 68 + .../tc/oc/pgm/xml/BoundedJDOMFactory.java | 27 + .../java/tc/oc/pgm/xml/BoundedSAXHandler.java | 27 + .../java/tc/oc/pgm/xml/ClonedElement.java | 40 + .../java/tc/oc/pgm/xml/ElementFlattener.java | 58 + .../java/tc/oc/pgm/xml/InheritingElement.java | 27 + .../tc/oc/pgm/xml/InvalidXMLException.java | 158 ++ PGM/src/main/java/tc/oc/pgm/xml/Node.java | 424 ++++++ .../main/java/tc/oc/pgm/xml/NodeSplitter.java | 42 + .../main/java/tc/oc/pgm/xml/Parseable.java | 63 + .../oc/pgm/xml/UnrecognizedXMLException.java | 31 + .../xml/UnsupportedMapProtocolException.java | 20 + .../tc/oc/pgm/xml/finder/AllChildren.java | 18 + .../java/tc/oc/pgm/xml/finder/Attributes.java | 19 + .../tc/oc/pgm/xml/finder/EmptyChildren.java | 21 + .../tc/oc/pgm/xml/finder/Grandchildren.java | 20 + .../tc/oc/pgm/xml/finder/NamedChildren.java | 18 + .../java/tc/oc/pgm/xml/finder/NodeFinder.java | 27 + .../java/tc/oc/pgm/xml/finder/Parent.java | 18 + .../java/tc/oc/pgm/xml/finder/ParentText.java | 19 + .../java/tc/oc/pgm/xml/parser/Aggregator.java | 141 ++ .../tc/oc/pgm/xml/parser/AttributeParser.java | 18 + .../tc/oc/pgm/xml/parser/BooleanParser.java | 25 + .../tc/oc/pgm/xml/parser/DurationParser.java | 33 + .../oc/pgm/xml/parser/ElementListParser.java | 22 + .../tc/oc/pgm/xml/parser/ElementParser.java | 31 + .../java/tc/oc/pgm/xml/parser/EnumParser.java | 31 + .../oc/pgm/xml/parser/EnumParserManifest.java | 30 + .../pgm/xml/parser/EnumPropertyManifest.java | 24 + .../oc/pgm/xml/parser/MaterialDataParser.java | 32 + .../tc/oc/pgm/xml/parser/MaterialParser.java | 19 + .../pgm/xml/parser/MessageTemplateParser.java | 43 + .../tc/oc/pgm/xml/parser/NumberParser.java | 45 + .../java/tc/oc/pgm/xml/parser/Parser.java | 39 + .../tc/oc/pgm/xml/parser/ParserBinders.java | 52 + .../tc/oc/pgm/xml/parser/ParserManifest.java | 81 ++ .../oc/pgm/xml/parser/ParserTypeLiterals.java | 40 + .../oc/pgm/xml/parser/PercentageParser.java | 25 + .../tc/oc/pgm/xml/parser/PrimitiveParser.java | 79 + .../tc/oc/pgm/xml/parser/PropertyParser.java | 165 +++ .../tc/oc/pgm/xml/parser/RangeParser.java | 31 + .../pgm/xml/parser/RangeParserManifest.java | 44 + .../oc/pgm/xml/parser/ReflectiveParser.java | 8 + .../xml/parser/ReflectiveParserManifest.java | 354 +++++ .../tc/oc/pgm/xml/parser/StringParser.java | 19 + .../oc/pgm/xml/parser/TeamRelationParser.java | 33 + .../oc/pgm/xml/parser/TransfiniteParser.java | 31 + .../tc/oc/pgm/xml/parser/VectorParser.java | 41 + .../pgm/xml/property/ComparableProperty.java | 23 + .../oc/pgm/xml/property/DurationProperty.java | 21 + .../xml/property/MessageTemplateProperty.java | 36 + .../oc/pgm/xml/property/NumberProperty.java | 28 + .../property/PercentagePropertyFactory.java | 26 + .../oc/pgm/xml/property/PropertyBuilder.java | 133 ++ .../xml/property/PropertyBuilderFactory.java | 7 + .../oc/pgm/xml/property/PropertyManifest.java | 64 + .../tc/oc/pgm/xml/property/RangeProperty.java | 15 + .../pgm/xml/property/TransfiniteProperty.java | 31 + .../tc/oc/pgm/xml/validate/DurationIs.java | 36 + .../pgm/xml/validate/LocatedValidation.java | 34 + .../oc/pgm/xml/validate/MaterialDataIs.java | 26 + .../tc/oc/pgm/xml/validate/MaterialIs.java | 19 + .../validate/MessageTemplateIsLocalized.java | 26 + .../java/tc/oc/pgm/xml/validate/NonBlank.java | 18 + .../tc/oc/pgm/xml/validate/Validatable.java | 38 + .../tc/oc/pgm/xml/validate/Validation.java | 61 + .../pgm/xml/validate/ValidationContext.java | 29 + PGM/src/main/resources/config.yml | 208 +++ PGM/src/main/resources/plugin.yml | 203 +++ .../tc/oc/pgm/filter/ItemMatcherTest.java | 160 +++ .../java/tc/oc/pgm/mutation/MutationTest.java | 21 + README.md | 220 +++ Tourney/pom.xml | 38 + .../anxuiz/tourney/ClassificationManager.java | 51 + Tourney/src/net/anxuiz/tourney/Config.java | 36 + .../src/net/anxuiz/tourney/KDMSession.java | 72 + .../net/anxuiz/tourney/MapClassification.java | 25 + .../src/net/anxuiz/tourney/MatchManager.java | 107 ++ .../src/net/anxuiz/tourney/ReadyManager.java | 72 + .../src/net/anxuiz/tourney/TeamManager.java | 274 ++++ Tourney/src/net/anxuiz/tourney/Tourney.java | 150 ++ .../net/anxuiz/tourney/TourneyManifest.java | 35 + .../anxuiz/tourney/TourneyPermissions.java | 8 + .../src/net/anxuiz/tourney/TourneyState.java | 18 + .../tourney/command/MapSelectionCommands.java | 182 +++ .../anxuiz/tourney/command/TeamCommands.java | 237 +++ .../tourney/command/TourneyCommands.java | 135 ++ .../tourney/event/EntrantRegisterEvent.java | 39 + .../tourney/event/EntrantUnregisterEvent.java | 36 + .../event/PartyReadyStatusChangeEvent.java | 62 + .../event/TourneyStateChangeEvent.java | 36 + .../mapselect/MapSelectionBeginEvent.java | 29 + ...MapSelectionClassificationSelectEvent.java | 29 + .../MapSelectionClassificationVetoEvent.java | 23 + .../event/mapselect/MapSelectionEvent.java | 17 + .../mapselect/MapSelectionMapSelectEvent.java | 29 + .../mapselect/MapSelectionMapVetoEvent.java | 23 + .../mapselect/MapSelectionTeamEvent.java | 25 + .../mapselect/MapSelectionTurnCycleEvent.java | 21 + .../tourney/listener/GameplayListener.java | 39 + .../anxuiz/tourney/listener/KDMListener.java | 64 + .../tourney/listener/ReadyListener.java | 108 ++ .../anxuiz/tourney/listener/TeamListener.java | 154 ++ Tourney/src/net/anxuiz/tourney/task/Task.java | 32 + .../net/anxuiz/tourney/util/EntrantUtils.java | 49 + .../src/net/anxuiz/tourney/vote/VetoVote.java | 45 + .../net/anxuiz/tourney/vote/VoteContext.java | 420 ++++++ Tourney/src/resources/config.yml | 7 + Tourney/src/resources/plugin.yml | 61 + Util/bukkit/pom.xml | 93 ++ .../bukkit/attribute/AttributeUtils.java | 30 + .../bukkit/bossbar/BossBarFactory.java | 15 + .../bukkit/bossbar/BossBarFactoryImpl.java | 40 + .../bukkit/bossbar/RenderedBossBar.java | 166 +++ .../tc/oc/commons/bukkit/chat/Audiences.java | 6 + .../bukkit/chat/BaseComponentRenderer.java | 69 + .../commons/bukkit/chat/BukkitAudiences.java | 32 + .../oc/commons/bukkit/chat/BukkitSound.java | 50 + .../bukkit/chat/CommandSenderAudience.java | 50 + .../bukkit/chat/ComponentRenderContext.java | 83 ++ .../bukkit/chat/ComponentRenderer.java | 21 + .../chat/ComponentRendererRegistry.java | 56 + .../bukkit/chat/ComponentRenderers.java | 79 + .../commons/bukkit/chat/ConsoleAudience.java | 26 + .../commons/bukkit/chat/HeaderComponent.java | 95 ++ .../oc/commons/bukkit/chat/ListComponent.java | 67 + .../commons/bukkit/chat/PlayerAudience.java | 40 + .../tc/oc/commons/bukkit/chat/Renderable.java | 15 + .../bukkit/chat/RenderableComponent.java | 6 + .../commons/bukkit/chat/WarningComponent.java | 52 + .../commands/BukkitCommandManifest.java | 21 + .../commands/BukkitCommandRegistry.java | 126 ++ .../bukkit/configuration/ConfigUtils.java | 154 ++ .../event/AdventureModeInteractEvent.java | 34 + .../commons/bukkit/event/BlockPunchEvent.java | 22 + .../bukkit/event/BlockTrampleEvent.java | 13 + .../event/BukkitEventHandlerScanner.java | 19 + .../bukkit/event/CoarsePlayerMoveEvent.java | 83 ++ .../bukkit/event/EventHandlerInfo.java | 72 + .../bukkit/event/EventHandlerScanner.java | 73 + .../tc/oc/commons/bukkit/event/EventKey.java | 40 + .../commons/bukkit/event/EventSubscriber.java | 29 + .../bukkit/event/ExtendedCancellable.java | 45 + .../bukkit/event/GeneralizingEvent.java | 53 + .../event/targeted/TargetedEventBus.java | 30 + .../event/targeted/TargetedEventBusImpl.java | 119 ++ .../event/targeted/TargetedEventHandler.java | 46 + .../targeted/TargetedEventHandlerScanner.java | 21 + .../event/targeted/TargetedEventManifest.java | 15 + .../event/targeted/TargetedEventRouter.java | 18 + .../targeted/TargetedEventRouterBinder.java | 23 + .../bukkit/geometry/AffineTransform.java | 252 ++++ .../oc/commons/bukkit/geometry/Capsule.java | 60 + .../oc/commons/bukkit/geometry/Direction.java | 65 + .../commons/bukkit/geometry/LineSegment.java | 108 ++ .../bukkit/geometry/LinearFunction.java | 77 + .../tc/oc/commons/bukkit/geometry/Sphere.java | 40 + .../bukkit/inject/BukkitFacetContext.java | 55 + .../bukkit/inject/BukkitPlayerModule.java | 29 + .../bukkit/inject/BukkitPluginManifest.java | 37 + .../bukkit/inject/BukkitPluginResolver.java | 30 + .../bukkit/inject/BukkitServerManifest.java | 56 + .../inject/ComponentRendererModule.java | 13 + .../commons/bukkit/inventory/ArmorType.java | 38 + .../bukkit/inventory/InventorySlot.java | 103 ++ .../bukkit/inventory/InventoryUtils.java | 74 + .../tc/oc/commons/bukkit/inventory/Slot.java | 494 +++++++ .../commons/bukkit/item/BooleanItemTag.java | 25 + .../oc/commons/bukkit/item/FloatItemTag.java | 25 + .../commons/bukkit/item/IntegerItemTag.java | 25 + .../oc/commons/bukkit/item/ItemBuilder.java | 175 +++ .../bukkit/item/ItemConfigurationParser.java | 129 ++ .../tc/oc/commons/bukkit/item/ItemTag.java | 89 ++ .../tc/oc/commons/bukkit/item/ItemUtils.java | 127 ++ .../bukkit/item/RenderedItemBuilder.java | 44 + .../oc/commons/bukkit/item/StringItemTag.java | 25 + .../bukkit/listeners/ButtonListener.java | 18 + .../bukkit/listeners/ButtonManager.java | 147 ++ .../listeners/PlayerMovementListener.java | 263 ++++ .../bukkit/listeners/WindowListener.java | 36 + .../bukkit/listeners/WindowManager.java | 125 ++ .../bukkit/localization/BukkitTranslator.java | 13 + .../localization/BukkitTranslatorImpl.java | 101 ++ .../bukkit/localization/Translator.java | 58 + .../bukkit/logging/BukkitLoggerFactory.java | 31 + .../bukkit/logging/ChatLogHandler.java | 46 + .../commons/bukkit/logging/ChatLogRecord.java | 36 + .../permissions/BukkitPermissionRegistry.java | 25 + .../permissions/PermissionRegistry.java | 11 + .../scheduler/BukkitSchedulerBackend.java | 54 + .../scheduler/BukkitSchedulerManifest.java | 23 + .../scheduler/DeferredSyncExecutor.java | 21 + .../scheduler/ImmediateSyncExecutor.java | 21 + .../bukkit/suspend/SuspendListener.java | 38 + .../tc/oc/commons/bukkit/util/BlockFaces.java | 75 + .../commons/bukkit/util/BlockMaterialMap.java | 644 +++++++++ .../commons/bukkit/util/BlockStateUtils.java | 50 + .../tc/oc/commons/bukkit/util/BlockUtils.java | 169 +++ .../commons/bukkit/util/BlockVectorSet.java | 224 +++ .../oc/commons/bukkit/util/BukkitEvents.java | 12 + .../oc/commons/bukkit/util/BukkitUtils.java | 208 +++ .../oc/commons/bukkit/util/ChunkLocation.java | 76 + .../oc/commons/bukkit/util/ChunkPosition.java | 70 + .../oc/commons/bukkit/util/ChunkVector.java | 98 ++ .../bukkit/util/ListeningMapAdapter.java | 124 ++ .../bukkit/util/LivingEntityMapAdapter.java | 44 + .../tc/oc/commons/bukkit/util/LongDeque.java | 153 ++ .../commons/bukkit/util/MaterialCounter.java | 136 ++ .../oc/commons/bukkit/util/MaterialUtils.java | 82 ++ .../tc/oc/commons/bukkit/util/Materials.java | 152 ++ .../tc/oc/commons/bukkit/util/NBTUtils.java | 64 + .../tc/oc/commons/bukkit/util/NMSHacks.java | 838 +++++++++++ .../bukkit/util/NullChunkGenerator.java | 14 + .../bukkit/util/NullCommandSender.java | 45 + .../commons/bukkit/util/NullPermissible.java | 146 ++ .../bukkit/util/OnlinePlayerMapAdapter.java | 40 + .../oc/commons/bukkit/util/PacketTracer.java | 632 ++++++++ .../bukkit/util/PotionClassification.java | 170 +++ .../oc/commons/bukkit/util/PotionUtils.java | 70 + .../tc/oc/commons/bukkit/util/Vectors.java | 48 + .../commons/bukkit/util/WorldBorderUtils.java | 50 + .../bukkit/util/materials/Banners.java | 50 + Util/bungee/pom.xml | 74 + .../tc/oc/commons/bungee/chat/Audiences.java | 6 + .../commons/bungee/chat/BungeeAudiences.java | 20 + .../commons/bungee/chat/ConsoleAudience.java | 18 + .../commons/bungee/chat/PlayerAudience.java | 49 + .../commands/BungeeCommandRegistry.java | 48 + .../bungee/configuration/ConfigUtils.java | 38 + .../bungee/inject/BungeePluginManifest.java | 36 + .../bungee/inject/BungeeServerManifest.java | 32 + .../bungee/logging/BungeeLoggerFactory.java | 31 + .../bungee/plugin/BungeePluginResolver.java | 36 + .../scheduler/BungeeSchedulerManifest.java | 24 + Util/core/pom.xml | 206 +++ .../java/tc/oc/commons/core/FileUtils.java | 88 ++ .../tc/oc/commons/core/IterableUtils.java | 327 +++++ .../java/tc/oc/commons/core/LiquidMetal.java | 82 ++ .../java/tc/oc/commons/core/ListUtils.java | 124 ++ .../commons/core/chat/AbstractAudience.java | 17 + .../core/chat/AbstractConsoleAudience.java | 25 + .../core/chat/AbstractMultiAudience.java | 50 + .../tc/oc/commons/core/chat/Audience.java | 55 + .../tc/oc/commons/core/chat/Audiences.java | 16 + .../oc/commons/core/chat/BlankComponent.java | 55 + .../tc/oc/commons/core/chat/ChatUtils.java | 541 +++++++ .../tc/oc/commons/core/chat/Component.java | 256 ++++ .../commons/core/chat/ComponentCollector.java | 39 + .../tc/oc/commons/core/chat/Components.java | 512 +++++++ .../commons/core/chat/ForwardingAudience.java | 50 + .../commons/core/chat/ImmutableComponent.java | 62 + .../commons/core/chat/MinecraftAudiences.java | 40 + .../oc/commons/core/chat/MultiAudience.java | 15 + .../tc/oc/commons/core/chat/NullAudience.java | 43 + .../java/tc/oc/commons/core/chat/Sound.java | 7 + .../core/collection/ConflictResolvingMap.java | 66 + .../core/collection/CountingStringMap.java | 23 + .../core/collection/FilteredBiMap.java | 71 + .../commons/core/collection/FilteredMap.java | 51 + .../commons/core/collection/FilteredSet.java | 23 + .../core/collection/FlatCollection.java | 30 + .../core/collection/ForwardingBiMap.java | 28 + .../commons/core/collection/InstantMap.java | 72 + .../core/collection/IterableHelper.java | 30 + .../oc/commons/core/collection/MapHelper.java | 96 ++ .../core/collection/StringIncrementer.java | 42 + .../oc/commons/core/collection/TableView.java | 172 +++ .../commons/core/commands/CommandBinder.java | 58 + .../commands/CommandExceptionHandler.java | 145 ++ .../core/commands/CommandFutureCallback.java | 87 ++ .../core/commands/CommandInvocationInfo.java | 71 + .../core/commands/CommandRegistry.java | 38 + .../core/commands/CommandRegistryImpl.java | 55 + .../tc/oc/commons/core/commands/Commands.java | 9 + .../core/commands/CommandsManifest.java | 16 + .../commands/ComponentCommandException.java | 18 + .../core/commands/GuiceInjectorAdapter.java | 28 + .../commons/core/commands/NestedCommands.java | 10 + .../TranslatableCommandException.java | 9 + .../AbstractContextualExecutor.java | 64 + .../core/concurrent/CatchingExecutor.java | 27 + .../concurrent/CatchingExecutorService.java | 79 + .../core/concurrent/CatchingRunnable.java | 43 + .../core/concurrent/ContextualExecutor.java | 27 + .../concurrent/ContextualExecutorImpl.java | 20 + .../concurrent/ExceptionHandlingExecutor.java | 26 + .../concurrent/ExecutorServiceDecorator.java | 145 ++ .../core/concurrent/ExecutorUtils.java | 76 + .../core/concurrent/FailureCallback.java | 23 + .../commons/core/concurrent/Flexecutor.java | 45 + .../commons/core/concurrent/FutureUtils.java | 104 ++ .../oc/commons/core/concurrent/LockMap.java | 35 + .../tc/oc/commons/core/concurrent/Locker.java | 52 + .../core/concurrent/SerializingExecutor.java | 38 + .../core/concurrent/SuccessCallback.java | 23 + .../core/concurrent/TimeoutFuture.java | 111 ++ .../core/configuration/ConfigUtils.java | 75 + .../core/configuration/TestConfiguration.java | 211 +++ .../core/configuration/YamlConfiguration.java | 14 + .../oc/commons/core/event/EventBusModule.java | 20 + .../core/event/EventExceptionHandler.java | 30 + .../tc/oc/commons/core/event/EventUtils.java | 26 + .../commons/core/event/ReentrantEventBus.java | 57 + .../core/exception/ExceptionHandler.java | 72 + .../exception/FutureExceptionHandler.java | 22 + .../exception/InvalidMemberException.java | 44 + .../core/exception/LambdaExceptionUtils.java | 127 ++ .../exception/LoggingExceptionHandler.java | 48 + .../core/exception/NamedThreadFactory.java | 12 + .../core/formatting/PeriodFormats.java | 99 ++ .../commons/core/formatting/StringUtils.java | 224 +++ .../core/inject/AbstractBindingBuilder.java | 72 + .../tc/oc/commons/core/inject/BelongsTo.java | 96 ++ .../tc/oc/commons/core/inject/Binders.java | 179 +++ .../inject/BindingTargetTypeResolver.java | 91 ++ .../tc/oc/commons/core/inject/Bindings.java | 35 + .../core/inject/ChildConfigurator.java | 80 ++ .../core/inject/ChildInjectorFactory.java | 45 + .../core/inject/ContextualProvider.java | 58 + .../core/inject/ContextualProviderModule.java | 30 + .../oc/commons/core/inject/Dependencies.java | 21 + .../core/inject/DependencyCollector.java | 231 +++ .../commons/core/inject/ElementExposer.java | 30 + .../commons/core/inject/ElementInspector.java | 211 +++ .../oc/commons/core/inject/ElementLogger.java | 26 + .../commons/core/inject/ElementPrinter.java | 20 + .../oc/commons/core/inject/ElementUtils.java | 80 ++ .../java/tc/oc/commons/core/inject/Facet.java | 23 + .../oc/commons/core/inject/FacetBinder.java | 48 + .../oc/commons/core/inject/FacetContext.java | 156 ++ .../tc/oc/commons/core/inject/Grapher.java | 25 + .../commons/core/inject/HybridManifest.java | 10 + .../commons/core/inject/InjectableMethod.java | 249 ++++ .../commons/core/inject/InjectingFactory.java | 44 + .../tc/oc/commons/core/inject/Injection.java | 260 ++++ .../commons/core/inject/InjectionChecks.java | 26 + .../commons/core/inject/InjectionLogger.java | 54 + .../commons/core/inject/InjectionRequest.java | 49 + .../core/inject/InjectionScopable.java | 24 + .../commons/core/inject/InjectionScope.java | 87 ++ .../commons/core/inject/InjectionStore.java | 43 + .../oc/commons/core/inject/InjectorScope.java | 20 + .../core/inject/InjectorScopeModule.java | 10 + .../commons/core/inject/InjectorScoped.java | 17 + .../oc/commons/core/inject/InnerFactory.java | 34 + .../core/inject/InnerFactoryManifest.java | 115 ++ .../oc/commons/core/inject/KeyValueStore.java | 71 + .../oc/commons/core/inject/KeyedManifest.java | 48 + .../java/tc/oc/commons/core/inject/Keys.java | 148 ++ .../tc/oc/commons/core/inject/Manifest.java | 43 + .../tc/oc/commons/core/inject/Matchers.java | 41 + .../core/inject/MemberInjectingFactory.java | 53 + .../commons/core/inject/OptionalProvider.java | 31 + .../commons/core/inject/PrivateBinders.java | 58 + .../commons/core/inject/ProtectedBinders.java | 37 + .../commons/core/inject/ProvidesGeneric.java | 13 + .../tc/oc/commons/core/inject/Proxied.java | 25 + .../commons/core/inject/ProxiedManifest.java | 33 + .../oc/commons/core/inject/ProxyProvider.java | 25 + .../core/inject/RepeatInjectionDetector.java | 74 + .../tc/oc/commons/core/inject/Scoper.java | 137 ++ .../tc/oc/commons/core/inject/SetBinder.java | 83 ++ .../core/inject/SingletonManifest.java | 18 + .../commons/core/inject/SubtypeListener.java | 8 + .../tc/oc/commons/core/inject/TestModule.java | 25 + .../core/inject/TransformableBinder.java | 133 ++ .../oc/commons/core/inject/Transformer.java | 17 + .../oc/commons/core/inject/TypeManifest.java | 71 + .../oc/commons/core/inject/TypeMapBinder.java | 87 ++ .../commons/core/inject/UtilCoreManifest.java | 65 + .../oc/commons/core/inspect/Inspectable.java | 87 ++ .../core/inspect/InspectableProperty.java | 75 + .../oc/commons/core/inspect/Inspection.java | 109 ++ .../core/inspect/InspectionException.java | 11 + .../tc/oc/commons/core/inspect/Inspector.java | 25 + .../core/inspect/MultiLineTextInspector.java | 102 ++ .../core/inspect/ReflectiveProperty.java | 90 ++ .../commons/core/inspect/TextInspector.java | 71 + .../core/localization/LocaleMatcher.java | 46 + .../oc/commons/core/localization/Locales.java | 68 + .../oc/commons/core/logging/ClassLogger.java | 146 ++ .../core/logging/ClassLoggerFactory.java | 15 + .../tc/oc/commons/core/logging/Loggers.java | 33 + .../tc/oc/commons/core/logging/Logging.java | 311 ++++ .../commons/core/logging/LoggingConfig.java | 46 + .../commons/core/logging/LoggingManifest.java | 12 + .../core/logging/PluginLoggerFactory.java | 23 + .../core/logging/SimpleLoggerFactory.java | 17 + .../core/plugin/AbstractPluginResolver.java | 107 ++ .../core/plugin/MinecraftPluginManifest.java | 17 + .../oc/commons/core/plugin/PluginFacet.java | 31 + .../core/plugin/PluginFacetBinder.java | 30 + .../core/plugin/PluginFacetLoader.java | 84 ++ .../core/plugin/PluginFacetManifest.java | 13 + .../commons/core/plugin/PluginResolver.java | 57 + .../oc/commons/core/plugin/PluginScoped.java | 18 + .../proxy/CachingMethodHandleInterceptor.java | 23 + .../proxy/MethodHandleDispatcherBase.java | 38 + .../core/proxy/MethodHandleInterceptor.java | 13 + .../commons/core/random/AdvancingEntropy.java | 17 + .../tc/oc/commons/core/random/Entropy.java | 36 + .../ImmutableWeightedRandomChooser.java | 71 + .../commons/core/random/MutableEntropy.java | 43 + .../random/MutableWeightedRandomChooser.java | 67 + .../oc/commons/core/random/RandomUtils.java | 16 + .../oc/commons/core/random/SaltedEntropy.java | 22 + .../core/random/WeightedRandomChooser.java | 76 + .../commons/core/reflect/AnnotationBase.java | 29 + .../oc/commons/core/reflect/Annotations.java | 101 ++ .../oc/commons/core/reflect/AutoReified.java | 22 + .../core/reflect/ClassFormException.java | 36 + .../tc/oc/commons/core/reflect/Delegates.java | 183 +++ .../core/reflect/ElementFormException.java | 10 + .../commons/core/reflect/FieldDelegate.java | 102 ++ .../tc/oc/commons/core/reflect/Fields.java | 186 +++ .../core/reflect/GenericMethodType.java | 102 ++ .../reflect/InheritablePropertyVisitor.java | 50 + .../tc/oc/commons/core/reflect/Members.java | 101 ++ .../core/reflect/MethodFormException.java | 41 + .../core/reflect/MethodHandleUtils.java | 85 ++ .../commons/core/reflect/MethodResolver.java | 85 ++ .../commons/core/reflect/MethodScanner.java | 139 ++ .../tc/oc/commons/core/reflect/Methods.java | 529 +++++++ .../core/reflect/MinimalSupertypeSet.java | 21 + .../commons/core/reflect/MinimalTypeSet.java | 57 + .../commons/core/reflect/PatternInvoker.java | 114 ++ .../core/reflect/ReflectionFormatting.java | 35 + .../core/reflect/ReflectionVisitor.java | 16 + .../tc/oc/commons/core/reflect/Reified.java | 22 + .../commons/core/reflect/ResolvableType.java | 74 + .../core/reflect/ResolvableTypeParameter.java | 54 + .../oc/commons/core/reflect/TypeArgument.java | 30 + .../oc/commons/core/reflect/TypeCapture.java | 17 + .../oc/commons/core/reflect/TypeLiterals.java | 61 + .../commons/core/reflect/TypeParameter.java | 88 ++ .../core/reflect/TypeParameterCache.java | 39 + .../oc/commons/core/reflect/TypeResolver.java | 45 + .../tc/oc/commons/core/reflect/Types.java | 515 +++++++ .../commons/core/scheduler/AbstractTask.java | 48 + .../commons/core/scheduler/DebouncedTask.java | 28 + .../core/scheduler/DisposableTask.java | 38 + .../commons/core/scheduler/ReusableTask.java | 43 + .../oc/commons/core/scheduler/Scheduler.java | 310 ++++ .../core/scheduler/SchedulerBackend.java | 35 + .../core/scheduler/SchedulerBackendImpl.java | 91 ++ .../tc/oc/commons/core/scheduler/Task.java | 149 ++ .../core/server/MinecraftServerManifest.java | 31 + .../tc/oc/commons/core/stream/BiStream.java | 116 ++ .../oc/commons/core/stream/BiStreamImpl.java | 10 + .../tc/oc/commons/core/stream/Collectors.java | 287 ++++ .../commons/core/stream/ForwardingStream.java | 231 +++ .../core/util/AmbiguousElementException.java | 22 + .../tc/oc/commons/core/util/ArrayUtils.java | 217 +++ .../core/util/BlockingScheduledQueue.java | 96 ++ .../main/java/tc/oc/commons/core/util/C3.java | 68 + .../tc/oc/commons/core/util/CacheUtils.java | 45 + .../core/util/CachingMethodHandleInvoker.java | 37 + .../oc/commons/core/util/CachingProvider.java | 36 + .../oc/commons/core/util/CachingTypeMap.java | 43 + .../java/tc/oc/commons/core/util/Chain.java | 143 ++ .../commons/core/util/CheckedCloseable.java | 9 + .../tc/oc/commons/core/util/Comparables.java | 27 + .../tc/oc/commons/core/util/Comparators.java | 83 ++ .../tc/oc/commons/core/util/Comparing.java | 11 + .../core/util/CompletableFutureCallback.java | 45 + .../java/tc/oc/commons/core/util/Counter.java | 25 + .../commons/core/util/DefaultMapAdapter.java | 164 +++ .../oc/commons/core/util/DefaultProvider.java | 5 + .../core/util/DuplicateElementException.java | 22 + .../tc/oc/commons/core/util/EnumSets.java | 21 + .../oc/commons/core/util/ExceptionUtils.java | 135 ++ .../tc/oc/commons/core/util/Forwarding.java | 27 + .../commons/core/util/FunctionalMatcher.java | 25 + .../tc/oc/commons/core/util/Functions.java | 34 + .../commons/core/util/HashingInputStream.java | 87 ++ .../tc/oc/commons/core/util/Holidays.java | 63 + .../commons/core/util/ImmutableTypeMap.java | 93 ++ .../commons/core/util/IndexedBiConsumer.java | 6 + .../oc/commons/core/util/IndexedConsumer.java | 6 + .../oc/commons/core/util/IndexedFunction.java | 6 + .../oc/commons/core/util/InheritingMap.java | 115 ++ .../oc/commons/core/util/IteratorUtils.java | 18 + .../java/tc/oc/commons/core/util/Joiners.java | 16 + .../java/tc/oc/commons/core/util/Lazy.java | 98 ++ .../commons/core/util/LinkedHashMultimap.java | 42 + .../tc/oc/commons/core/util/MapUtils.java | 183 +++ .../java/tc/oc/commons/core/util/Maybe.java | 224 +++ .../core/util/MethodHandleInvoker.java | 57 + .../oc/commons/core/util/MultimapHelper.java | 15 + .../tc/oc/commons/core/util/Nullables.java | 74 + .../oc/commons/core/util/NumberFactory.java | 95 ++ .../java/tc/oc/commons/core/util/Numbers.java | 153 ++ .../oc/commons/core/util/OptionalUtils.java | 14 + .../tc/oc/commons/core/util/Optionals.java | 170 +++ .../tc/oc/commons/core/util/Orderable.java | 29 + .../java/tc/oc/commons/core/util/Pair.java | 81 ++ .../tc/oc/commons/core/util/Predicates.java | 13 + .../tc/oc/commons/core/util/ProxyUtils.java | 75 + .../tc/oc/commons/core/util/PunchClock.java | 170 +++ .../java/tc/oc/commons/core/util/Ranges.java | 129 ++ .../tc/oc/commons/core/util/RankedSet.java | 159 +++ .../tc/oc/commons/core/util/StackTrace.java | 55 + .../java/tc/oc/commons/core/util/Streams.java | 243 ++++ .../tc/oc/commons/core/util/SupersetView.java | 88 ++ .../core/util/SystemFutureCallback.java | 96 ++ .../tc/oc/commons/core/util/Threadable.java | 62 + .../commons/core/util/ThrowingBiConsumer.java | 23 + .../commons/core/util/ThrowingConsumer.java | 26 + .../commons/core/util/ThrowingFunction.java | 28 + .../commons/core/util/ThrowingRunnable.java | 17 + .../commons/core/util/ThrowingSupplier.java | 20 + .../tc/oc/commons/core/util/TimeUtils.java | 235 +++ .../tc/oc/commons/core/util/Traceable.java | 7 + .../commons/core/util/TraceableWrapper.java | 21 + .../tc/oc/commons/core/util/Traceables.java | 71 + .../java/tc/oc/commons/core/util/TypeMap.java | 140 ++ .../oc/commons/core/util/UsageCollection.java | 87 ++ .../java/tc/oc/commons/core/util/Utils.java | 41 + .../tc/oc/debug/DeterministicHashcode.java | 50 + .../main/java/tc/oc/debug/LeakDetector.java | 17 + .../java/tc/oc/debug/LeakDetectorConfig.java | 5 + .../java/tc/oc/debug/LeakDetectorImpl.java | 167 +++ .../tc/oc/debug/LeakDetectorManifest.java | 19 + .../src/main/java/tc/oc/evil/Decorator.java | 16 + .../java/tc/oc/evil/DecoratorFactory.java | 129 ++ .../java/tc/oc/evil/DecoratorGenerator.java | 36 + .../java/tc/oc/evil/DecoratorProvider.java | 44 + .../oc/evil/JavassistDecoratorGenerator.java | 87 ++ .../tc/oc/evil/LibCGDecoratorGenerator.java | 122 ++ .../src/main/java/tc/oc/file/PathWatcher.java | 12 + .../java/tc/oc/file/PathWatcherHandle.java | 5 + .../java/tc/oc/file/PathWatcherService.java | 9 + .../tc/oc/file/PathWatcherServiceImpl.java | 227 +++ .../main/java/tc/oc/javassist/Javassists.java | 233 +++ .../minecraft/protocol/MinecraftVersion.java | 56 + .../scheduler/ExecutorServiceWrapper.java | 21 + .../scheduler/MainThreadExecutor.java | 12 + .../scheduler/MinecraftExecutorManifest.java | 82 ++ .../scheduler/PluginExecutorBase.java | 88 ++ .../java/tc/oc/minecraft/scheduler/Sync.java | 39 + .../oc/minecraft/scheduler/SyncExecutor.java | 12 + .../tc/oc/minecraft/suspend/Suspendable.java | 31 + .../minecraft/suspend/SuspendableBinder.java | 10 + .../java/tc/oc/parse/EnumParserManifest.java | 32 + .../java/tc/oc/parse/FormatException.java | 11 + .../java/tc/oc/parse/MissingException.java | 8 + .../main/java/tc/oc/parse/ParseException.java | 19 + .../src/main/java/tc/oc/parse/Parser.java | 17 + .../java/tc/oc/parse/ParserTypeLiterals.java | 61 + .../java/tc/oc/parse/ParsersManifest.java | 27 + .../tc/oc/parse/PrimitiveParserManifest.java | 40 + .../main/java/tc/oc/parse/ValueException.java | 19 + .../tc/oc/parse/primitive/BooleanParser.java | 18 + .../tc/oc/parse/primitive/DurationParser.java | 21 + .../tc/oc/parse/primitive/EnumParser.java | 45 + .../tc/oc/parse/primitive/PathParser.java | 20 + .../tc/oc/parse/validate/AbsolutePath.java | 14 + .../tc/oc/parse/validate/NonZeroDuration.java | 15 + .../tc/oc/parse/validate/NormalizedPath.java | 14 + .../tc/oc/parse/validate/RelativePath.java | 14 + .../java/tc/oc/parse/validate/Validation.java | 7 + .../java/tc/oc/parse/xml/DocumentParser.java | 9 + .../java/tc/oc/parse/xml/ElementParser.java | 9 + .../main/java/tc/oc/parse/xml/NodeParser.java | 21 + .../tc/oc/parse/xml/PrimitiveNodeParser.java | 30 + .../parse/xml/UnrecognizedNodeException.java | 10 + .../tc/oc/parse/xml/ValidatingNodeParser.java | 38 + .../src/main/java/tc/oc/parse/xml/XML.java | 65 + .../java/tc/oc/parse/xml/XMLManifest.java | 22 + .../java/tc/oc/test/InjectedTestCase.java | 30 + .../java/tc/oc/test/mockito/MockBinder.java | 26 + .../java/tc/oc/test/mockito/MockProvider.java | 28 + Util/core/src/main/java/tc/oc/time/Clock.java | 13 + .../main/java/tc/oc/time/FriendlyUnits.java | 54 + .../src/main/java/tc/oc/time/Interval.java | 29 + .../main/java/tc/oc/time/PeriodConverter.java | 13 + .../java/tc/oc/time/PeriodConverters.java | 72 + .../main/java/tc/oc/time/PeriodRenderer.java | 13 + .../main/java/tc/oc/time/PeriodRenderers.java | 36 + Util/core/src/main/java/tc/oc/time/Time.java | 45 + .../src/main/java/tc/oc/time/TimePeriod.java | 109 ++ .../core/inject/InjectableMethodTest.java | 125 ++ .../core/inject/TransformableBinderTest.java | 94 ++ .../tc/oc/commons/random/EntropyTest.java | 70 + .../oc/commons/random/SaltedEntropyTest.java | 45 + .../random/WeightedRandomChooserTest.java | 104 ++ .../oc/commons/reflect/AnnotationsTest.java | 57 + .../commons/reflect/CallableMethodTest.java | 78 + .../tc/oc/commons/reflect/DelegatesTest.java | 55 + .../oc/commons/reflect/DynamicLambdaTest.java | 177 +++ .../reflect/FunctionalInterfaceUtilsTest.java | 43 + .../oc/commons/reflect/MethodScannerTest.java | 79 + .../oc/commons/reflect/TypeCoercionTest.java | 65 + .../oc/commons/util/ResolvableTypeTest.java | 21 + .../tc/oc/commons/util/StreamUtilsTest.java | 37 + .../java/tc/oc/evil/DecoratorFactoryTest.java | 113 ++ Util/pom.xml | 38 + bukkit.yml.sample | 62 + pom.xml | 268 ++++ 2109 files changed, 152113 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 API/api/pom.xml create mode 100644 API/api/src/main/java/tc/oc/api/ApiManifest.java create mode 100644 API/api/src/main/java/tc/oc/api/annotations/ApiRequired.java create mode 100644 API/api/src/main/java/tc/oc/api/annotations/Serialize.java create mode 100644 API/api/src/main/java/tc/oc/api/config/ApiConfiguration.java create mode 100644 API/api/src/main/java/tc/oc/api/config/ApiConstants.java create mode 100644 API/api/src/main/java/tc/oc/api/connectable/Connectable.java create mode 100644 API/api/src/main/java/tc/oc/api/connectable/ConnectableBinder.java create mode 100644 API/api/src/main/java/tc/oc/api/connectable/ConnectablesManifest.java create mode 100644 API/api/src/main/java/tc/oc/api/connectable/Connector.java create mode 100644 API/api/src/main/java/tc/oc/api/docs/AbstractModel.java create mode 100644 API/api/src/main/java/tc/oc/api/docs/Arena.java create mode 100644 API/api/src/main/java/tc/oc/api/docs/BasicDeletableModel.java create mode 100644 API/api/src/main/java/tc/oc/api/docs/BasicModel.java create mode 100644 API/api/src/main/java/tc/oc/api/docs/Death.java create mode 100644 API/api/src/main/java/tc/oc/api/docs/Entrant.java create mode 100644 API/api/src/main/java/tc/oc/api/docs/Game.java create mode 100644 API/api/src/main/java/tc/oc/api/docs/MapRating.java create mode 100644 API/api/src/main/java/tc/oc/api/docs/MatchState.java create mode 100644 API/api/src/main/java/tc/oc/api/docs/Objective.java create mode 100644 API/api/src/main/java/tc/oc/api/docs/Participation.java create mode 100644 API/api/src/main/java/tc/oc/api/docs/PlayerId.java create mode 100644 API/api/src/main/java/tc/oc/api/docs/Punishment.java create mode 100644 API/api/src/main/java/tc/oc/api/docs/Report.java create mode 100644 API/api/src/main/java/tc/oc/api/docs/SemanticVersion.java create mode 100644 API/api/src/main/java/tc/oc/api/docs/Server.java create mode 100644 API/api/src/main/java/tc/oc/api/docs/Session.java create mode 100644 API/api/src/main/java/tc/oc/api/docs/SimplePlayerId.java create mode 100644 API/api/src/main/java/tc/oc/api/docs/SimpleUserId.java create mode 100644 API/api/src/main/java/tc/oc/api/docs/Ticket.java create mode 100644 API/api/src/main/java/tc/oc/api/docs/Tournament.java create mode 100644 API/api/src/main/java/tc/oc/api/docs/Trophy.java create mode 100644 API/api/src/main/java/tc/oc/api/docs/User.java create mode 100644 API/api/src/main/java/tc/oc/api/docs/UserId.java create mode 100644 API/api/src/main/java/tc/oc/api/docs/Whisper.java create mode 100644 API/api/src/main/java/tc/oc/api/docs/team.java create mode 100644 API/api/src/main/java/tc/oc/api/docs/virtual/BasicDocument.java create mode 100644 API/api/src/main/java/tc/oc/api/docs/virtual/CompetitorDoc.java create mode 100644 API/api/src/main/java/tc/oc/api/docs/virtual/DeathDoc.java create mode 100644 API/api/src/main/java/tc/oc/api/docs/virtual/DeletableModel.java create mode 100644 API/api/src/main/java/tc/oc/api/docs/virtual/DeployInfo.java create mode 100644 API/api/src/main/java/tc/oc/api/docs/virtual/Document.java create mode 100644 API/api/src/main/java/tc/oc/api/docs/virtual/EngagementDoc.java create mode 100644 API/api/src/main/java/tc/oc/api/docs/virtual/EngagementDocBase.java create mode 100644 API/api/src/main/java/tc/oc/api/docs/virtual/MapDoc.java create mode 100644 API/api/src/main/java/tc/oc/api/docs/virtual/MatchDoc.java create mode 100644 API/api/src/main/java/tc/oc/api/docs/virtual/Model.java create mode 100644 API/api/src/main/java/tc/oc/api/docs/virtual/PartialModel.java create mode 100644 API/api/src/main/java/tc/oc/api/docs/virtual/PunishmentDoc.java create mode 100644 API/api/src/main/java/tc/oc/api/docs/virtual/ReportDoc.java create mode 100644 API/api/src/main/java/tc/oc/api/docs/virtual/ServerDoc.java create mode 100644 API/api/src/main/java/tc/oc/api/docs/virtual/SessionDoc.java create mode 100644 API/api/src/main/java/tc/oc/api/docs/virtual/UserDoc.java create mode 100644 API/api/src/main/java/tc/oc/api/docs/virtual/WhisperDoc.java create mode 100644 API/api/src/main/java/tc/oc/api/document/Accessor.java create mode 100644 API/api/src/main/java/tc/oc/api/document/BaseAccessor.java create mode 100644 API/api/src/main/java/tc/oc/api/document/DocumentGenerator.java create mode 100644 API/api/src/main/java/tc/oc/api/document/DocumentMeta.java create mode 100644 API/api/src/main/java/tc/oc/api/document/DocumentRegistry.java create mode 100644 API/api/src/main/java/tc/oc/api/document/DocumentSerializer.java create mode 100644 API/api/src/main/java/tc/oc/api/document/DocumentsManifest.java create mode 100644 API/api/src/main/java/tc/oc/api/document/FieldAccessor.java create mode 100644 API/api/src/main/java/tc/oc/api/document/FieldGetter.java create mode 100644 API/api/src/main/java/tc/oc/api/document/FieldSetter.java create mode 100644 API/api/src/main/java/tc/oc/api/document/Getter.java create mode 100644 API/api/src/main/java/tc/oc/api/document/GetterMethod.java create mode 100644 API/api/src/main/java/tc/oc/api/document/ProxyDocumentGenerator.java create mode 100644 API/api/src/main/java/tc/oc/api/document/Setter.java create mode 100644 API/api/src/main/java/tc/oc/api/document/SetterMethod.java create mode 100644 API/api/src/main/java/tc/oc/api/engagement/EngagementModelManifest.java create mode 100644 API/api/src/main/java/tc/oc/api/engagement/EngagementService.java create mode 100644 API/api/src/main/java/tc/oc/api/engagement/EngagementUpdateRequest.java create mode 100644 API/api/src/main/java/tc/oc/api/engagement/LocalEngagementService.java create mode 100644 API/api/src/main/java/tc/oc/api/exceptions/ApiException.java create mode 100644 API/api/src/main/java/tc/oc/api/exceptions/ApiNotConnected.java create mode 100644 API/api/src/main/java/tc/oc/api/exceptions/Conflict.java create mode 100644 API/api/src/main/java/tc/oc/api/exceptions/Forbidden.java create mode 100644 API/api/src/main/java/tc/oc/api/exceptions/NotFound.java create mode 100644 API/api/src/main/java/tc/oc/api/exceptions/SerializationException.java create mode 100644 API/api/src/main/java/tc/oc/api/exceptions/UnknownMessageType.java create mode 100644 API/api/src/main/java/tc/oc/api/exceptions/UnmappedUserException.java create mode 100644 API/api/src/main/java/tc/oc/api/exceptions/UnprocessableEntity.java create mode 100644 API/api/src/main/java/tc/oc/api/games/ArenaStore.java create mode 100644 API/api/src/main/java/tc/oc/api/games/GameModelManifest.java create mode 100644 API/api/src/main/java/tc/oc/api/games/GameStore.java create mode 100644 API/api/src/main/java/tc/oc/api/games/NullTicketService.java create mode 100644 API/api/src/main/java/tc/oc/api/games/TicketService.java create mode 100644 API/api/src/main/java/tc/oc/api/games/TicketStore.java create mode 100644 API/api/src/main/java/tc/oc/api/http/HttpClient.java create mode 100644 API/api/src/main/java/tc/oc/api/http/HttpClientConfiguration.java create mode 100644 API/api/src/main/java/tc/oc/api/http/HttpClientConfigurationImpl.java create mode 100644 API/api/src/main/java/tc/oc/api/http/HttpManifest.java create mode 100644 API/api/src/main/java/tc/oc/api/http/HttpOption.java create mode 100644 API/api/src/main/java/tc/oc/api/http/QueryUri.java create mode 100644 API/api/src/main/java/tc/oc/api/maps/MapModelManifest.java create mode 100644 API/api/src/main/java/tc/oc/api/maps/MapRatingsRequest.java create mode 100644 API/api/src/main/java/tc/oc/api/maps/MapRatingsResponse.java create mode 100644 API/api/src/main/java/tc/oc/api/maps/MapService.java create mode 100644 API/api/src/main/java/tc/oc/api/maps/MapUpdateMultiResponse.java create mode 100644 API/api/src/main/java/tc/oc/api/maps/NullMapService.java create mode 100644 API/api/src/main/java/tc/oc/api/match/DeathSearchRequest.java create mode 100644 API/api/src/main/java/tc/oc/api/match/MatchModelManifest.java create mode 100644 API/api/src/main/java/tc/oc/api/message/Message.java create mode 100644 API/api/src/main/java/tc/oc/api/message/MessageBinder.java create mode 100644 API/api/src/main/java/tc/oc/api/message/MessageHandler.java create mode 100644 API/api/src/main/java/tc/oc/api/message/MessageListener.java create mode 100644 API/api/src/main/java/tc/oc/api/message/MessageMeta.java create mode 100644 API/api/src/main/java/tc/oc/api/message/MessageQueue.java create mode 100644 API/api/src/main/java/tc/oc/api/message/MessageRegistry.java create mode 100644 API/api/src/main/java/tc/oc/api/message/MessagesManifest.java create mode 100644 API/api/src/main/java/tc/oc/api/message/NoSuchMessageException.java create mode 100644 API/api/src/main/java/tc/oc/api/message/NullMessageQueue.java create mode 100644 API/api/src/main/java/tc/oc/api/message/types/CycleRequest.java create mode 100644 API/api/src/main/java/tc/oc/api/message/types/CycleResponse.java create mode 100644 API/api/src/main/java/tc/oc/api/message/types/FindMultiRequest.java create mode 100644 API/api/src/main/java/tc/oc/api/message/types/FindMultiResponse.java create mode 100644 API/api/src/main/java/tc/oc/api/message/types/FindRequest.java create mode 100644 API/api/src/main/java/tc/oc/api/message/types/ModelDelete.java create mode 100644 API/api/src/main/java/tc/oc/api/message/types/ModelMessage.java create mode 100644 API/api/src/main/java/tc/oc/api/message/types/ModelUpdate.java create mode 100644 API/api/src/main/java/tc/oc/api/message/types/PartialModelUpdate.java create mode 100644 API/api/src/main/java/tc/oc/api/message/types/Ping.java create mode 100644 API/api/src/main/java/tc/oc/api/message/types/PlayGameRequest.java create mode 100644 API/api/src/main/java/tc/oc/api/message/types/PlayerTeleportRequest.java create mode 100644 API/api/src/main/java/tc/oc/api/message/types/Reply.java create mode 100644 API/api/src/main/java/tc/oc/api/message/types/ServerUpdateRequest.java create mode 100644 API/api/src/main/java/tc/oc/api/message/types/UpdateMultiRequest.java create mode 100644 API/api/src/main/java/tc/oc/api/message/types/UpdateMultiResponse.java create mode 100644 API/api/src/main/java/tc/oc/api/model/BatchUpdateRequest.java create mode 100644 API/api/src/main/java/tc/oc/api/model/BatchUpdater.java create mode 100644 API/api/src/main/java/tc/oc/api/model/BatchUpdaterFactory.java create mode 100644 API/api/src/main/java/tc/oc/api/model/HttpModelService.java create mode 100644 API/api/src/main/java/tc/oc/api/model/HttpQueryService.java create mode 100644 API/api/src/main/java/tc/oc/api/model/IdFactory.java create mode 100644 API/api/src/main/java/tc/oc/api/model/ModelBinder.java create mode 100644 API/api/src/main/java/tc/oc/api/model/ModelBinders.java create mode 100644 API/api/src/main/java/tc/oc/api/model/ModelDispatcher.java create mode 100644 API/api/src/main/java/tc/oc/api/model/ModelHandler.java create mode 100644 API/api/src/main/java/tc/oc/api/model/ModelListener.java create mode 100644 API/api/src/main/java/tc/oc/api/model/ModelListenerBinder.java create mode 100644 API/api/src/main/java/tc/oc/api/model/ModelMeta.java create mode 100644 API/api/src/main/java/tc/oc/api/model/ModelName.java create mode 100644 API/api/src/main/java/tc/oc/api/model/ModelRegistry.java create mode 100644 API/api/src/main/java/tc/oc/api/model/ModelService.java create mode 100644 API/api/src/main/java/tc/oc/api/model/ModelStore.java create mode 100644 API/api/src/main/java/tc/oc/api/model/ModelSync.java create mode 100644 API/api/src/main/java/tc/oc/api/model/ModelTypeLiterals.java create mode 100644 API/api/src/main/java/tc/oc/api/model/ModelsManifest.java create mode 100644 API/api/src/main/java/tc/oc/api/model/NoSuchModelException.java create mode 100644 API/api/src/main/java/tc/oc/api/model/NullModelService.java create mode 100644 API/api/src/main/java/tc/oc/api/model/NullQueryService.java create mode 100644 API/api/src/main/java/tc/oc/api/model/QueryService.java create mode 100644 API/api/src/main/java/tc/oc/api/model/UpdateService.java create mode 100644 API/api/src/main/java/tc/oc/api/punishments/PunishmentModelManifest.java create mode 100644 API/api/src/main/java/tc/oc/api/punishments/PunishmentSearchRequest.java create mode 100644 API/api/src/main/java/tc/oc/api/queue/Consume.java create mode 100644 API/api/src/main/java/tc/oc/api/queue/Delivery.java create mode 100644 API/api/src/main/java/tc/oc/api/queue/Exchange.java create mode 100644 API/api/src/main/java/tc/oc/api/queue/MessageDefaults.java create mode 100644 API/api/src/main/java/tc/oc/api/queue/Metadata.java create mode 100644 API/api/src/main/java/tc/oc/api/queue/PrimaryQueue.java create mode 100644 API/api/src/main/java/tc/oc/api/queue/Publish.java create mode 100644 API/api/src/main/java/tc/oc/api/queue/Queue.java create mode 100644 API/api/src/main/java/tc/oc/api/queue/QueueClient.java create mode 100644 API/api/src/main/java/tc/oc/api/queue/QueueClientConfiguration.java create mode 100644 API/api/src/main/java/tc/oc/api/queue/QueueClientConfigurationImpl.java create mode 100644 API/api/src/main/java/tc/oc/api/queue/QueueManifest.java create mode 100644 API/api/src/main/java/tc/oc/api/queue/QueueQueryService.java create mode 100644 API/api/src/main/java/tc/oc/api/queue/Transaction.java create mode 100644 API/api/src/main/java/tc/oc/api/reports/ReportModelManifest.java create mode 100644 API/api/src/main/java/tc/oc/api/reports/ReportSearchRequest.java create mode 100644 API/api/src/main/java/tc/oc/api/serialization/DurationTypeAdapter.java create mode 100644 API/api/src/main/java/tc/oc/api/serialization/GsonBinder.java create mode 100644 API/api/src/main/java/tc/oc/api/serialization/InetAddressTypeAdapter.java create mode 100644 API/api/src/main/java/tc/oc/api/serialization/InstantTypeAdapter.java create mode 100644 API/api/src/main/java/tc/oc/api/serialization/JsonDebugReader.java create mode 100644 API/api/src/main/java/tc/oc/api/serialization/JsonUtils.java create mode 100644 API/api/src/main/java/tc/oc/api/serialization/LenientEnumSetTypeAdapter.java create mode 100644 API/api/src/main/java/tc/oc/api/serialization/NullableTypeAdapter.java create mode 100644 API/api/src/main/java/tc/oc/api/serialization/PathTypeAdapter.java create mode 100644 API/api/src/main/java/tc/oc/api/serialization/PlayerIdTypeAdapter.java create mode 100644 API/api/src/main/java/tc/oc/api/serialization/Pretty.java create mode 100644 API/api/src/main/java/tc/oc/api/serialization/SemanticVersionTypeAdapter.java create mode 100644 API/api/src/main/java/tc/oc/api/serialization/SerializationManifest.java create mode 100644 API/api/src/main/java/tc/oc/api/serialization/StrictEnumTypeAdapter.java create mode 100644 API/api/src/main/java/tc/oc/api/serialization/TypeAdaptersManifest.java create mode 100644 API/api/src/main/java/tc/oc/api/serialization/UserIdTypeAdapter.java create mode 100644 API/api/src/main/java/tc/oc/api/serialization/UuidTypeAdapter.java create mode 100644 API/api/src/main/java/tc/oc/api/servers/BungeeMetricRequest.java create mode 100644 API/api/src/main/java/tc/oc/api/servers/NullServerService.java create mode 100644 API/api/src/main/java/tc/oc/api/servers/PingRequest.java create mode 100644 API/api/src/main/java/tc/oc/api/servers/PingResult.java create mode 100644 API/api/src/main/java/tc/oc/api/servers/ServerModelManifest.java create mode 100644 API/api/src/main/java/tc/oc/api/servers/ServerSearchRequest.java create mode 100644 API/api/src/main/java/tc/oc/api/servers/ServerService.java create mode 100644 API/api/src/main/java/tc/oc/api/servers/ServerStore.java create mode 100644 API/api/src/main/java/tc/oc/api/sessions/BadNickname.java create mode 100644 API/api/src/main/java/tc/oc/api/sessions/NullSessionService.java create mode 100644 API/api/src/main/java/tc/oc/api/sessions/SessionChange.java create mode 100644 API/api/src/main/java/tc/oc/api/sessions/SessionModelManifest.java create mode 100644 API/api/src/main/java/tc/oc/api/sessions/SessionService.java create mode 100644 API/api/src/main/java/tc/oc/api/sessions/SessionStartRequest.java create mode 100644 API/api/src/main/java/tc/oc/api/tourney/NullTournamentService.java create mode 100644 API/api/src/main/java/tc/oc/api/tourney/RecordMatchResponse.java create mode 100644 API/api/src/main/java/tc/oc/api/tourney/TeamUtils.java create mode 100644 API/api/src/main/java/tc/oc/api/tourney/TournamentModelManifest.java create mode 100644 API/api/src/main/java/tc/oc/api/tourney/TournamentService.java create mode 100644 API/api/src/main/java/tc/oc/api/tourney/TournamentStore.java create mode 100644 API/api/src/main/java/tc/oc/api/trophies/TrophyModelManifest.java create mode 100644 API/api/src/main/java/tc/oc/api/trophies/TrophyStore.java create mode 100644 API/api/src/main/java/tc/oc/api/users/ChangeClassRequest.java create mode 100644 API/api/src/main/java/tc/oc/api/users/ChangeSettingRequest.java create mode 100644 API/api/src/main/java/tc/oc/api/users/CreditRaindropsRequest.java create mode 100644 API/api/src/main/java/tc/oc/api/users/LoginRequest.java create mode 100644 API/api/src/main/java/tc/oc/api/users/LoginResponse.java create mode 100644 API/api/src/main/java/tc/oc/api/users/LogoutRequest.java create mode 100644 API/api/src/main/java/tc/oc/api/users/NullUserService.java create mode 100644 API/api/src/main/java/tc/oc/api/users/PurchaseGizmoRequest.java create mode 100644 API/api/src/main/java/tc/oc/api/users/UserModelManifest.java create mode 100644 API/api/src/main/java/tc/oc/api/users/UserSearchRequest.java create mode 100644 API/api/src/main/java/tc/oc/api/users/UserSearchResponse.java create mode 100644 API/api/src/main/java/tc/oc/api/users/UserService.java create mode 100644 API/api/src/main/java/tc/oc/api/users/UserUpdateResponse.java create mode 100644 API/api/src/main/java/tc/oc/api/users/UserUtils.java create mode 100644 API/api/src/main/java/tc/oc/api/util/Permissions.java create mode 100644 API/api/src/main/java/tc/oc/api/util/UUIDs.java create mode 100644 API/api/src/main/java/tc/oc/api/whispers/NullWhisperService.java create mode 100644 API/api/src/main/java/tc/oc/api/whispers/WhisperModelManifest.java create mode 100644 API/api/src/main/java/tc/oc/api/whispers/WhisperService.java create mode 100644 API/api/src/main/resources/config.yml create mode 100644 API/api/src/test/java/tc/oc/ApiTest.java create mode 100644 API/api/src/test/java/tc/oc/document/ClassDoc.java create mode 100644 API/api/src/test/java/tc/oc/document/DocumentDeserializationTest.java create mode 100644 API/api/src/test/java/tc/oc/document/DocumentGeneratorTest.java create mode 100644 API/api/src/test/java/tc/oc/document/DocumentSerializationTest.java create mode 100644 API/api/src/test/java/tc/oc/document/GenericFieldInterfaceDoc.java create mode 100644 API/api/src/test/java/tc/oc/document/GenericInterfaceDoc.java create mode 100644 API/api/src/test/java/tc/oc/document/InterfaceDoc.java create mode 100644 API/api/src/test/java/tc/oc/message/MessageRegistryTest.java create mode 100644 API/bukkit/pom.xml create mode 100644 API/bukkit/src/main/java/tc/oc/api/bukkit/BukkitApiManifest.java create mode 100644 API/bukkit/src/main/java/tc/oc/api/bukkit/event/UserUpdateEvent.java create mode 100644 API/bukkit/src/main/java/tc/oc/api/bukkit/friends/OnlineFriends.java create mode 100644 API/bukkit/src/main/java/tc/oc/api/bukkit/users/BukkitUserStore.java create mode 100644 API/bukkit/src/main/java/tc/oc/api/bukkit/users/OnlinePlayers.java create mode 100644 API/bukkit/src/main/java/tc/oc/api/bukkit/users/Users.java create mode 100644 API/bukkit/src/main/resources/plugin.yml create mode 100644 API/bungee/pom.xml create mode 100644 API/bungee/src/main/java/tc/oc/api/bungee/BungeeApiManifest.java create mode 100644 API/bungee/src/main/java/tc/oc/api/bungee/users/BungeeUserStore.java create mode 100644 API/bungee/src/main/java/tc/oc/api/bungee/users/OnlinePlayers.java create mode 100644 API/bungee/src/main/resources/plugin.yml create mode 100644 API/minecraft/pom.xml create mode 100644 API/minecraft/src/main/java/tc/oc/api/minecraft/MinecraftApiManifest.java create mode 100644 API/minecraft/src/main/java/tc/oc/api/minecraft/MinecraftService.java create mode 100644 API/minecraft/src/main/java/tc/oc/api/minecraft/MinecraftServiceImpl.java create mode 100644 API/minecraft/src/main/java/tc/oc/api/minecraft/config/MinecraftApiConfiguration.java create mode 100644 API/minecraft/src/main/java/tc/oc/api/minecraft/config/MinecraftApiConfigurationImpl.java create mode 100644 API/minecraft/src/main/java/tc/oc/api/minecraft/logging/LoggingCommands.java create mode 100644 API/minecraft/src/main/java/tc/oc/api/minecraft/logging/MinecraftLoggingManifest.java create mode 100644 API/minecraft/src/main/java/tc/oc/api/minecraft/logging/NotOurProblemRavenFilter.java create mode 100644 API/minecraft/src/main/java/tc/oc/api/minecraft/logging/RavenServerTagger.java create mode 100644 API/minecraft/src/main/java/tc/oc/api/minecraft/maps/LocalMapService.java create mode 100644 API/minecraft/src/main/java/tc/oc/api/minecraft/maps/MinecraftMapsManifest.java create mode 100644 API/minecraft/src/main/java/tc/oc/api/minecraft/model/MinecraftModelsManifest.java create mode 100644 API/minecraft/src/main/java/tc/oc/api/minecraft/model/ModelCommands.java create mode 100644 API/minecraft/src/main/java/tc/oc/api/minecraft/queue/MinecraftQueueManifest.java create mode 100644 API/minecraft/src/main/java/tc/oc/api/minecraft/queue/QueueCommands.java create mode 100644 API/minecraft/src/main/java/tc/oc/api/minecraft/servers/LocalServerDocument.java create mode 100644 API/minecraft/src/main/java/tc/oc/api/minecraft/servers/LocalServerReconfigureEvent.java create mode 100644 API/minecraft/src/main/java/tc/oc/api/minecraft/servers/LocalServerService.java create mode 100644 API/minecraft/src/main/java/tc/oc/api/minecraft/servers/MinecraftServersManifest.java create mode 100644 API/minecraft/src/main/java/tc/oc/api/minecraft/servers/StartupServerDocument.java create mode 100644 API/minecraft/src/main/java/tc/oc/api/minecraft/sessions/LocalSessionFactory.java create mode 100644 API/minecraft/src/main/java/tc/oc/api/minecraft/sessions/LocalSessionService.java create mode 100644 API/minecraft/src/main/java/tc/oc/api/minecraft/sessions/MinecraftSessionsManifest.java create mode 100644 API/minecraft/src/main/java/tc/oc/api/minecraft/users/LocalUserDocument.java create mode 100644 API/minecraft/src/main/java/tc/oc/api/minecraft/users/LocalUserService.java create mode 100644 API/minecraft/src/main/java/tc/oc/api/minecraft/users/MinecraftUsersManifest.java create mode 100644 API/minecraft/src/main/java/tc/oc/api/minecraft/users/OnlinePlayers.java create mode 100644 API/minecraft/src/main/java/tc/oc/api/minecraft/users/UserStore.java create mode 100644 API/ocn/pom.xml create mode 100644 API/ocn/src/main/java/tc/oc/api/ocn/OCNApiManifest.java create mode 100644 API/ocn/src/main/java/tc/oc/api/ocn/OCNEngagementService.java create mode 100644 API/ocn/src/main/java/tc/oc/api/ocn/OCNMapService.java create mode 100644 API/ocn/src/main/java/tc/oc/api/ocn/OCNModelsManifest.java create mode 100644 API/ocn/src/main/java/tc/oc/api/ocn/OCNServerService.java create mode 100644 API/ocn/src/main/java/tc/oc/api/ocn/OCNSessionService.java create mode 100644 API/ocn/src/main/java/tc/oc/api/ocn/OCNTicketService.java create mode 100644 API/ocn/src/main/java/tc/oc/api/ocn/OCNTournamentService.java create mode 100644 API/ocn/src/main/java/tc/oc/api/ocn/OCNUserService.java create mode 100644 API/ocn/src/main/java/tc/oc/api/ocn/OCNWhisperService.java create mode 100644 API/ocn/src/main/resources/plugin.yml create mode 100644 API/pom.xml create mode 100644 Commons/bukkit/pom.xml create mode 100644 Commons/bukkit/src/main/java/tc/oc/bukkit/analytics/BukkitPlayerReporter.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/bukkit/analytics/LatencyReporter.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/bukkit/analytics/TickReporter.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/CommonsBukkitManifest.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/broadcast/BroadcastFormatter.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/broadcast/BroadcastManifest.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/broadcast/BroadcastParser.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/broadcast/BroadcastScheduler.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/broadcast/BroadcastSettings.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/broadcast/model/BroadcastPrefix.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/broadcast/model/BroadcastSchedule.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/broadcast/model/BroadcastSet.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/channels/AdminChannel.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/channels/AdminChatManifest.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/CachingNameRenderer.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/ComponentPaginator.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/FlairRenderer.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/FullNameRenderer.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/LinkComponent.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/Links.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/NameFlag.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/NameRenderer.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/NameStyle.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/NameType.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/Named.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/Paginator.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/PartialNameRenderer.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/PlayerComponent.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/PlayerComponentRenderer.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/StyledNameFunction.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/TemplateComponent.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/TextComponentRenderer.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/TranslatableComponentRenderer.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/UserTextComponent.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/UserTextComponentRenderer.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/UserURI.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/commands/CommandUtils.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/commands/PermissionCommands.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/commands/PrettyPaginatedResult.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/commands/ServerCommands.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/commands/ServerVisibilityCommands.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/commands/SkinCommands.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/commands/TraceCommands.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/commands/UserCommands.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/commands/UserFinder.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/config/ExternalConfiguration.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/debug/LeakListener.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/event/AsyncUserLoginEvent.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/event/ObserverKitApplyEvent.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/event/PlayerServerChangeEvent.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/event/UserEvent.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/event/UserLoginEvent.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/event/WhitelistStateChangeEvent.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/format/GameFormatter.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/format/MiscFormatter.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/format/ServerFormatter.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/format/UserFormatter.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/freeze/FrozenPlayer.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/freeze/PlayerFreezer.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/hologram/Hologram.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/hologram/HologramUtil.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/hologram/content/HologramAnimation.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/hologram/content/HologramContent.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/hologram/content/HologramFrame.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/listeners/AppealAlertListener.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/listeners/InactivePlayerListener.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/listeners/LocaleListener.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/listeners/LoginListener.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/listeners/PermissionGroupListener.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/localization/CommonsTranslations.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/localization/LocalizationManifest.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/localization/LocalizedDocument.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/localization/LocalizedMessageMap.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/localization/MessageMapParser.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/localization/MessageTemplate.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/localization/PluginLocales.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/localization/PluginTranslations.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/localization/Translations.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/logging/MapdevLogger.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/logging/MapdevSentryConfiguration.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/markup/MarkupParser.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/nick/ConsoleIdentity.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/nick/Familiarity.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/nick/Identity.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/nick/IdentityImpl.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/nick/IdentityProvider.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/nick/IdentityProviderImpl.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/nick/NicknameCommands.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/nick/NicknameConfiguration.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/nick/PlayerAppearanceChanger.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/nick/PlayerAppearanceListener.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/nick/PlayerIdentityChangeEvent.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/nick/PlayerOrder.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/nick/PlayerOrderCache.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/nick/UsernameRenderer.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/punishment/PunishmentCommands.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/punishment/PunishmentCreator.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/punishment/PunishmentEnforcer.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/punishment/PunishmentFormatter.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/punishment/PunishmentManifest.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/punishment/PunishmentMessageSetting.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/punishment/PunishmentPermissions.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/raindrops/PlayerRecieveRaindropsEvent.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/raindrops/RaindropCommands.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/raindrops/RaindropConstants.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/raindrops/RaindropManifest.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/raindrops/RaindropResult.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/raindrops/RaindropUtil.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/report/ReportAnnouncer.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/report/ReportCommands.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/report/ReportConfiguration.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/report/ReportCreator.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/report/ReportFormatter.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/report/ReportPermissions.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/respack/ResourcePackCommands.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/respack/ResourcePackListener.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/respack/ResourcePackManager.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/restart/RestartCommands.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/sessions/SessionListener.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/settings/RemoteTeleport.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/settings/SettingBinder.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/settings/SettingCallbackBinder.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/settings/SettingManagerProvider.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/settings/SettingManagerProviderImpl.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/settings/SettingManifest.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/tablist/BlankTabEntry.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/tablist/DynamicTabEntry.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/tablist/PlayerTabEntry.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/tablist/SimpleTabEntry.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/tablist/StaticTabEntry.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/tablist/TabEntry.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/tablist/TabManager.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/tablist/TabRender.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/tablist/TabView.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/teleport/FeaturedServerTracker.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/teleport/Navigator.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/teleport/NavigatorInterface.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/teleport/NavigatorManifest.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/teleport/PlayerServerChanger.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/teleport/TeleportCommands.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/teleport/TeleportListener.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/teleport/Teleporter.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/ticket/TicketBooth.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/ticket/TicketCommands.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/ticket/TicketDisplay.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/ticket/TicketListener.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/trophies/TrophyCase.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/trophies/TrophyCommands.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/trophies/TrophyEvent.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/trophies/TrophyPermissions.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/users/JoinMessageAnnouncer.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/users/JoinMessageConfiguration.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/users/JoinMessageManifest.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/users/JoinMessageSetting.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/users/PlayerSearchResponse.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/util/PermissionUtils.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/util/PlayerStates.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/util/PlayerStatesImpl.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/util/SyncPlayerExecutorFactory.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/whisper/WhisperCommands.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/whisper/WhisperDispatcher.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/whisper/WhisperFormatter.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/whisper/WhisperManifest.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/whisper/WhisperSender.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/whisper/WhisperSettings.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/whitelist/Whitelist.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/commons/bukkit/whitelist/WhitelistCommands.java create mode 100644 Commons/bukkit/src/main/java/tc/oc/parse/DocumentWatcher.java create mode 100644 Commons/bukkit/src/main/resources/config.yml create mode 100644 Commons/bukkit/src/main/resources/plugin.yml create mode 100644 Commons/bukkit/src/test/java/tc/oc/commons/CommonsBukkitTest.java create mode 100644 Commons/bukkit/src/test/java/tc/oc/commons/bukkit/geometry/CapsuleTest.java create mode 100644 Commons/bukkit/src/test/java/tc/oc/commons/bukkit/geometry/LineSegmentTest.java create mode 100644 Commons/bukkit/src/test/java/tc/oc/commons/bukkit/localization/LocalesTest.java create mode 100644 Commons/bukkit/src/test/java/tc/oc/commons/bukkit/localization/TranslationKeyTests.java create mode 100644 Commons/bukkit/src/test/java/tc/oc/commons/bukkit/util/PotionClassificationTest.java create mode 100644 Commons/bungee/pom.xml create mode 100644 Commons/bungee/src/main/java/tc/oc/bungee/analytics/BungeePlayerReporter.java create mode 100644 Commons/bungee/src/main/java/tc/oc/bungee/analytics/PlayerTimeoutReporter.java create mode 100644 Commons/bungee/src/main/java/tc/oc/commons/bungee/CommonsBungeeManifest.java create mode 100644 Commons/bungee/src/main/java/tc/oc/commons/bungee/commands/ServerCommands.java create mode 100644 Commons/bungee/src/main/java/tc/oc/commons/bungee/listeners/LoginListener.java create mode 100644 Commons/bungee/src/main/java/tc/oc/commons/bungee/listeners/MetricListener.java create mode 100644 Commons/bungee/src/main/java/tc/oc/commons/bungee/listeners/PingListener.java create mode 100644 Commons/bungee/src/main/java/tc/oc/commons/bungee/listeners/PlayerServerRouter.java create mode 100644 Commons/bungee/src/main/java/tc/oc/commons/bungee/listeners/TeleportListener.java create mode 100644 Commons/bungee/src/main/java/tc/oc/commons/bungee/restart/RestartListener.java create mode 100644 Commons/bungee/src/main/java/tc/oc/commons/bungee/servers/LobbyTracker.java create mode 100644 Commons/bungee/src/main/java/tc/oc/commons/bungee/servers/ServerTracker.java create mode 100644 Commons/bungee/src/main/java/tc/oc/commons/bungee/sessions/MojangSessionServiceCommands.java create mode 100644 Commons/bungee/src/main/java/tc/oc/commons/bungee/sessions/MojangSessionServiceMonitor.java create mode 100644 Commons/bungee/src/main/java/tc/oc/commons/bungee/sessions/SessionState.java create mode 100644 Commons/bungee/src/main/resources/config.yml create mode 100644 Commons/bungee/src/main/resources/plugin.yml create mode 100644 Commons/core/build.xml create mode 100644 Commons/core/pom.xml create mode 100644 Commons/core/src/main/i18n/templates/adminchat/AdminChatErrors.properties create mode 100644 Commons/core/src/main/i18n/templates/adminchat/AdminChatMessages.properties create mode 100644 Commons/core/src/main/i18n/templates/chatmoderator/ChatModeratorErrors.properties create mode 100644 Commons/core/src/main/i18n/templates/chatmoderator/ChatModeratorMessages.properties create mode 100644 Commons/core/src/main/i18n/templates/commons/Commons.properties create mode 100644 Commons/core/src/main/i18n/templates/lobby/LobbyErrors.properties create mode 100644 Commons/core/src/main/i18n/templates/lobby/LobbyMessages.properties create mode 100644 Commons/core/src/main/i18n/templates/lobby/LobbyMiscellaneous.properties create mode 100644 Commons/core/src/main/i18n/templates/lobby/LobbyUI.properties create mode 100644 Commons/core/src/main/i18n/templates/pgm/PGMDeath.properties create mode 100644 Commons/core/src/main/i18n/templates/pgm/PGMErrors.properties create mode 100644 Commons/core/src/main/i18n/templates/pgm/PGMMessages.properties create mode 100644 Commons/core/src/main/i18n/templates/pgm/PGMMiscellaneous.properties create mode 100644 Commons/core/src/main/i18n/templates/pgm/PGMUI.properties create mode 100644 Commons/core/src/main/i18n/templates/projectares/PAErrors.properties create mode 100644 Commons/core/src/main/i18n/templates/projectares/PAMessages.properties create mode 100644 Commons/core/src/main/i18n/templates/projectares/PAUI.properties create mode 100644 Commons/core/src/main/i18n/templates/raindrops/RaindropsMessages.properties create mode 100644 Commons/core/src/main/i18n/templates/tourney/Tourney.properties create mode 100644 Commons/core/src/main/java/tc/oc/analytics/AnalyticsClient.java create mode 100644 Commons/core/src/main/java/tc/oc/analytics/AnalyticsManifest.java create mode 100644 Commons/core/src/main/java/tc/oc/analytics/Count.java create mode 100644 Commons/core/src/main/java/tc/oc/analytics/Distribution.java create mode 100644 Commons/core/src/main/java/tc/oc/analytics/DynamicTagger.java create mode 100644 Commons/core/src/main/java/tc/oc/analytics/Event.java create mode 100644 Commons/core/src/main/java/tc/oc/analytics/Gauge.java create mode 100644 Commons/core/src/main/java/tc/oc/analytics/Metric.java create mode 100644 Commons/core/src/main/java/tc/oc/analytics/MetricFactory.java create mode 100644 Commons/core/src/main/java/tc/oc/analytics/StageTagger.java create mode 100644 Commons/core/src/main/java/tc/oc/analytics/Tag.java create mode 100644 Commons/core/src/main/java/tc/oc/analytics/TagSetBuilder.java create mode 100644 Commons/core/src/main/java/tc/oc/analytics/Tagger.java create mode 100644 Commons/core/src/main/java/tc/oc/analytics/TaggerBinder.java create mode 100644 Commons/core/src/main/java/tc/oc/analytics/datadog/DataDogClient.java create mode 100644 Commons/core/src/main/java/tc/oc/analytics/datadog/DataDogConfig.java create mode 100644 Commons/core/src/main/java/tc/oc/analytics/datadog/DataDogManifest.java create mode 100644 Commons/core/src/main/java/tc/oc/commons/core/CommonsCoreManifest.java create mode 100644 Commons/core/src/main/java/tc/oc/commons/core/commands/DebugCommands.java create mode 100644 Commons/core/src/main/java/tc/oc/commons/core/format/GeneralFormatter.java create mode 100644 Commons/core/src/main/java/tc/oc/commons/core/localization/Formats.java create mode 100644 Commons/core/src/main/java/tc/oc/commons/core/localization/LocaleMap.java create mode 100644 Commons/core/src/main/java/tc/oc/commons/core/localization/LocalizedFileManager.java create mode 100644 Commons/core/src/main/java/tc/oc/commons/core/localization/TranslationSet.java create mode 100644 Commons/core/src/main/java/tc/oc/commons/core/restart/CancelRestartEvent.java create mode 100644 Commons/core/src/main/java/tc/oc/commons/core/restart/RequestRestartEvent.java create mode 100644 Commons/core/src/main/java/tc/oc/commons/core/restart/RestartConfiguration.java create mode 100644 Commons/core/src/main/java/tc/oc/commons/core/restart/RestartManager.java create mode 100644 Commons/core/src/main/java/tc/oc/minecraft/analytics/AnalyticsFacet.java create mode 100644 Commons/core/src/main/java/tc/oc/minecraft/analytics/MinecraftAnalyticsManifest.java create mode 100644 Commons/core/src/main/java/tc/oc/minecraft/analytics/PlayerReporter.java create mode 100644 Commons/core/src/main/java/tc/oc/minecraft/analytics/ServerTagger.java create mode 100644 Commons/core/src/main/java/tc/oc/minecraft/server/ServerFilter.java create mode 100644 Commons/core/src/main/java/tc/oc/minecraft/server/ServerFilterManifest.java create mode 100644 Commons/core/src/main/java/tc/oc/minecraft/server/ServerFilterParser.java create mode 100644 Commons/core/src/test/java/tc/oc/commons/core/util/IterableUtilsTest.java create mode 100644 Commons/core/src/test/java/tc/oc/commons/core/util/ListUtilsTest.java create mode 100644 Commons/pom.xml create mode 100644 LICENSE.txt create mode 100644 Lobby/pom.xml create mode 100644 Lobby/src/main/java/tc/oc/lobby/bukkit/Lobby.java create mode 100644 Lobby/src/main/java/tc/oc/lobby/bukkit/LobbyConfig.java create mode 100644 Lobby/src/main/java/tc/oc/lobby/bukkit/LobbyManifest.java create mode 100644 Lobby/src/main/java/tc/oc/lobby/bukkit/LobbyTranslations.java create mode 100644 Lobby/src/main/java/tc/oc/lobby/bukkit/Settings.java create mode 100644 Lobby/src/main/java/tc/oc/lobby/bukkit/SignUpdater.java create mode 100644 Lobby/src/main/java/tc/oc/lobby/bukkit/Utils.java create mode 100644 Lobby/src/main/java/tc/oc/lobby/bukkit/gizmos/Gizmo.java create mode 100644 Lobby/src/main/java/tc/oc/lobby/bukkit/gizmos/GizmoConfig.java create mode 100644 Lobby/src/main/java/tc/oc/lobby/bukkit/gizmos/GizmoUtils.java create mode 100644 Lobby/src/main/java/tc/oc/lobby/bukkit/gizmos/Gizmos.java create mode 100644 Lobby/src/main/java/tc/oc/lobby/bukkit/gizmos/chicken/ChickenGizmo.java create mode 100644 Lobby/src/main/java/tc/oc/lobby/bukkit/gizmos/empty/EmptyGizmo.java create mode 100644 Lobby/src/main/java/tc/oc/lobby/bukkit/gizmos/gun/GunGizmo.java create mode 100644 Lobby/src/main/java/tc/oc/lobby/bukkit/gizmos/launcher/LauncherGizmo.java create mode 100644 Lobby/src/main/java/tc/oc/lobby/bukkit/gizmos/popper/PopperGizmo.java create mode 100644 Lobby/src/main/java/tc/oc/lobby/bukkit/gizmos/rocket/Rocket.java create mode 100644 Lobby/src/main/java/tc/oc/lobby/bukkit/gizmos/rocket/RocketGizmo.java create mode 100644 Lobby/src/main/java/tc/oc/lobby/bukkit/gizmos/rocket/RocketTask.java create mode 100644 Lobby/src/main/java/tc/oc/lobby/bukkit/gizmos/rocket/RocketUtils.java create mode 100644 Lobby/src/main/java/tc/oc/lobby/bukkit/listeners/EnvironmentControlListener.java create mode 100644 Lobby/src/main/java/tc/oc/lobby/bukkit/listeners/PlayerListener.java create mode 100644 Lobby/src/main/java/tc/oc/lobby/bukkit/listeners/PortalsListener.java create mode 100644 Lobby/src/main/java/tc/oc/lobby/bukkit/listeners/RaindropsListener.java create mode 100644 Lobby/src/main/java/tc/oc/lobby/bukkit/portals/Portal.java create mode 100644 Lobby/src/main/java/tc/oc/lobby/bukkit/portals/PortalsConfig.java create mode 100644 Lobby/src/main/resources/config.yml create mode 100644 Lobby/src/main/resources/plugin.yml create mode 100644 PGM/pom.xml create mode 100644 PGM/src/main/java/tc/oc/pgm/Config.java create mode 100644 PGM/src/main/java/tc/oc/pgm/MapModulesManifest.java create mode 100644 PGM/src/main/java/tc/oc/pgm/PGM.java create mode 100644 PGM/src/main/java/tc/oc/pgm/PGMManifest.java create mode 100644 PGM/src/main/java/tc/oc/pgm/PGMModulesManifest.java create mode 100644 PGM/src/main/java/tc/oc/pgm/PGMTranslations.java create mode 100644 PGM/src/main/java/tc/oc/pgm/analytics/MatchAnalyticsManifest.java create mode 100644 PGM/src/main/java/tc/oc/pgm/analytics/MatchPlayerReporter.java create mode 100644 PGM/src/main/java/tc/oc/pgm/analytics/MatchTagger.java create mode 100644 PGM/src/main/java/tc/oc/pgm/antigrief/AntiGrief.java create mode 100644 PGM/src/main/java/tc/oc/pgm/antigrief/CraftingProtect.java create mode 100644 PGM/src/main/java/tc/oc/pgm/antigrief/DefuseListener.java create mode 100644 PGM/src/main/java/tc/oc/pgm/api/EngagementMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/api/MatchDocument.java create mode 100644 PGM/src/main/java/tc/oc/pgm/api/MatchPublishingMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/api/ParticipationPublishingMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/blitz/BlitzConfig.java create mode 100644 PGM/src/main/java/tc/oc/pgm/blitz/BlitzMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/blitz/BlitzMatchResult.java create mode 100644 PGM/src/main/java/tc/oc/pgm/blitz/BlitzModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/blitz/LifeManager.java create mode 100644 PGM/src/main/java/tc/oc/pgm/blockdrops/BlockDrops.java create mode 100644 PGM/src/main/java/tc/oc/pgm/blockdrops/BlockDropsMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/blockdrops/BlockDropsModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/blockdrops/BlockDropsRule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/blockdrops/BlockDropsRuleSet.java create mode 100644 PGM/src/main/java/tc/oc/pgm/bossbar/BossBarContent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/bossbar/BossBarMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/bossbar/BossBarSource.java create mode 100644 PGM/src/main/java/tc/oc/pgm/broadcast/Broadcast.java create mode 100644 PGM/src/main/java/tc/oc/pgm/broadcast/BroadcastManifest.java create mode 100644 PGM/src/main/java/tc/oc/pgm/broadcast/BroadcastParser.java create mode 100644 PGM/src/main/java/tc/oc/pgm/broadcast/BroadcastScheduler.java create mode 100644 PGM/src/main/java/tc/oc/pgm/channels/ChannelCommands.java create mode 100644 PGM/src/main/java/tc/oc/pgm/channels/ChannelMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/channels/FilteredPartyChannel.java create mode 100644 PGM/src/main/java/tc/oc/pgm/channels/PartyChannel.java create mode 100644 PGM/src/main/java/tc/oc/pgm/channels/UnfilteredPartyChannel.java create mode 100644 PGM/src/main/java/tc/oc/pgm/chat/MatchFlairRenderer.java create mode 100644 PGM/src/main/java/tc/oc/pgm/chat/MatchNameInvalidator.java create mode 100644 PGM/src/main/java/tc/oc/pgm/chat/MatchUsernameRenderer.java create mode 100644 PGM/src/main/java/tc/oc/pgm/classes/ClassCommands.java create mode 100644 PGM/src/main/java/tc/oc/pgm/classes/ClassManifest.java create mode 100644 PGM/src/main/java/tc/oc/pgm/classes/ClassMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/classes/ClassModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/classes/PlayerClass.java create mode 100644 PGM/src/main/java/tc/oc/pgm/classes/PlayerClassChangeEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/commands/AdminCommands.java create mode 100644 PGM/src/main/java/tc/oc/pgm/commands/CommandUtils.java create mode 100644 PGM/src/main/java/tc/oc/pgm/commands/MapCommands.java create mode 100644 PGM/src/main/java/tc/oc/pgm/commands/MatchCommands.java create mode 100644 PGM/src/main/java/tc/oc/pgm/commands/PollCommands.java create mode 100644 PGM/src/main/java/tc/oc/pgm/commands/RotationControlCommands.java create mode 100644 PGM/src/main/java/tc/oc/pgm/commands/RotationEditCommands.java create mode 100644 PGM/src/main/java/tc/oc/pgm/compose/All.java create mode 100644 PGM/src/main/java/tc/oc/pgm/compose/Any.java create mode 100644 PGM/src/main/java/tc/oc/pgm/compose/ComposableManifest.java create mode 100644 PGM/src/main/java/tc/oc/pgm/compose/Composition.java create mode 100644 PGM/src/main/java/tc/oc/pgm/compose/CompositionParser.java create mode 100644 PGM/src/main/java/tc/oc/pgm/compose/Maybe.java create mode 100644 PGM/src/main/java/tc/oc/pgm/compose/None.java create mode 100644 PGM/src/main/java/tc/oc/pgm/compose/Unit.java create mode 100644 PGM/src/main/java/tc/oc/pgm/controlpoint/ControlPoint.java create mode 100644 PGM/src/main/java/tc/oc/pgm/controlpoint/ControlPointAnnouncer.java create mode 100644 PGM/src/main/java/tc/oc/pgm/controlpoint/ControlPointBlockDisplay.java create mode 100644 PGM/src/main/java/tc/oc/pgm/controlpoint/ControlPointDefinition.java create mode 100644 PGM/src/main/java/tc/oc/pgm/controlpoint/ControlPointManifest.java create mode 100644 PGM/src/main/java/tc/oc/pgm/controlpoint/ControlPointMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/controlpoint/ControlPointParser.java create mode 100644 PGM/src/main/java/tc/oc/pgm/controlpoint/ControlPointPlayerTracker.java create mode 100644 PGM/src/main/java/tc/oc/pgm/controlpoint/ControlPointRootNodeFinder.java create mode 100644 PGM/src/main/java/tc/oc/pgm/controlpoint/events/CapturingTeamChangeEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/controlpoint/events/CapturingTimeChangeEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/controlpoint/events/ControlPointEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/controlpoint/events/ControllerChangeEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/cooldown/CooldownPlayerFacet.java create mode 100644 PGM/src/main/java/tc/oc/pgm/core/Core.java create mode 100644 PGM/src/main/java/tc/oc/pgm/core/CoreBlockBreakEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/core/CoreContribution.java create mode 100644 PGM/src/main/java/tc/oc/pgm/core/CoreConvertMonitor.java create mode 100644 PGM/src/main/java/tc/oc/pgm/core/CoreEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/core/CoreFactory.java create mode 100644 PGM/src/main/java/tc/oc/pgm/core/CoreLeakEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/core/CoreManifest.java create mode 100644 PGM/src/main/java/tc/oc/pgm/core/CoreMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/core/CoreParser.java create mode 100644 PGM/src/main/java/tc/oc/pgm/countdowns/Countdown.java create mode 100644 PGM/src/main/java/tc/oc/pgm/countdowns/CountdownBossBarSource.java create mode 100644 PGM/src/main/java/tc/oc/pgm/countdowns/CountdownContext.java create mode 100644 PGM/src/main/java/tc/oc/pgm/countdowns/MatchCountdown.java create mode 100644 PGM/src/main/java/tc/oc/pgm/countdowns/MultiCountdownContext.java create mode 100644 PGM/src/main/java/tc/oc/pgm/countdowns/SingleCountdownContext.java create mode 100644 PGM/src/main/java/tc/oc/pgm/crafting/CraftingMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/crafting/CraftingModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/cycle/CycleCommands.java create mode 100644 PGM/src/main/java/tc/oc/pgm/cycle/CycleConfig.java create mode 100644 PGM/src/main/java/tc/oc/pgm/cycle/CycleMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/damage/DamageDisplayPlayerFacet.java create mode 100644 PGM/src/main/java/tc/oc/pgm/damage/DamageManifest.java create mode 100644 PGM/src/main/java/tc/oc/pgm/damage/DamageMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/damage/DamageModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/damage/DamageSettings.java create mode 100644 PGM/src/main/java/tc/oc/pgm/damage/DisableDamageMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/damage/DisableDamageModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/damage/HitboxMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/damage/HitboxPlayerFacet.java create mode 100644 PGM/src/main/java/tc/oc/pgm/death/DeathMessageBuilder.java create mode 100644 PGM/src/main/java/tc/oc/pgm/death/DeathMessageMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/death/DeathMessageSetting.java create mode 100644 PGM/src/main/java/tc/oc/pgm/death/HighlightDeathMessageSetting.java create mode 100644 PGM/src/main/java/tc/oc/pgm/debug/PGMLeakListener.java create mode 100644 PGM/src/main/java/tc/oc/pgm/destroyable/Destroyable.java create mode 100644 PGM/src/main/java/tc/oc/pgm/destroyable/DestroyableCommands.java create mode 100644 PGM/src/main/java/tc/oc/pgm/destroyable/DestroyableContribution.java create mode 100644 PGM/src/main/java/tc/oc/pgm/destroyable/DestroyableDestroyedEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/destroyable/DestroyableEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/destroyable/DestroyableFactory.java create mode 100644 PGM/src/main/java/tc/oc/pgm/destroyable/DestroyableHealthChange.java create mode 100644 PGM/src/main/java/tc/oc/pgm/destroyable/DestroyableHealthChangeEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/destroyable/DestroyableManifest.java create mode 100644 PGM/src/main/java/tc/oc/pgm/destroyable/DestroyableMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/destroyable/DestroyableParser.java create mode 100644 PGM/src/main/java/tc/oc/pgm/development/MapDevelopmentCommands.java create mode 100644 PGM/src/main/java/tc/oc/pgm/development/MapErrorTracker.java create mode 100644 PGM/src/main/java/tc/oc/pgm/doublejump/DoubleJumpKit.java create mode 100644 PGM/src/main/java/tc/oc/pgm/doublejump/DoubleJumpMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/effect/BloodMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/effect/LongRangeExplosionMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/effect/ProjectileTrailMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/eventrules/EventRule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/eventrules/EventRuleContext.java create mode 100644 PGM/src/main/java/tc/oc/pgm/eventrules/EventRuleMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/eventrules/EventRuleModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/eventrules/EventRuleParser.java create mode 100644 PGM/src/main/java/tc/oc/pgm/eventrules/EventRuleScope.java create mode 100644 PGM/src/main/java/tc/oc/pgm/events/BlockTransformEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/events/CompetitorAddEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/events/CompetitorRemoveEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/events/ConfigLoadEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/events/CycleEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/events/FeatureChangeEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/events/ItemTransferEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/events/ListenerScope.java create mode 100644 PGM/src/main/java/tc/oc/pgm/events/MapArchiveEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/events/MatchBeginEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/events/MatchEndEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/events/MatchEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/events/MatchLoadEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/events/MatchPlayerAddEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/events/MatchPlayerDamageEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/events/MatchPlayerDeathEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/events/MatchPlayerEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/events/MatchPostCommitEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/events/MatchPreCommitEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/events/MatchResultChangeEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/events/MatchScoreChangeEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/events/MatchStateChangeEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/events/MatchUnloadEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/events/MatchUserAddEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/events/MatchUserEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/events/ObserverInteractEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/events/ParticipantBlockTransformEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/events/PartyAddEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/events/PartyEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/events/PartyRemoveEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/events/PartyRenameEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/events/PlayerBlockTransformEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/events/PlayerChangePartyEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/events/PlayerItemTransferEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/events/PlayerJoinMatchEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/events/PlayerJoinPartyEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/events/PlayerLeaveMatchEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/events/PlayerLeavePartyEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/events/PlayerParticipationEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/events/PlayerParticipationStartEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/events/PlayerParticipationStopEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/events/PlayerPartyChangeEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/events/PlayerPartyChangeEventBase.java create mode 100644 PGM/src/main/java/tc/oc/pgm/events/PlayerResetEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/events/SetNextMapEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/events/SingleMatchPlayerEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/fallingblocks/FallingBlocksMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/fallingblocks/FallingBlocksModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/fallingblocks/FallingBlocksRule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/features/Feature.java create mode 100644 PGM/src/main/java/tc/oc/pgm/features/FeatureBase.java create mode 100644 PGM/src/main/java/tc/oc/pgm/features/FeatureBinder.java create mode 100644 PGM/src/main/java/tc/oc/pgm/features/FeatureDefinition.java create mode 100644 PGM/src/main/java/tc/oc/pgm/features/FeatureDefinitionContext.java create mode 100644 PGM/src/main/java/tc/oc/pgm/features/FeatureDefinitionException.java create mode 100644 PGM/src/main/java/tc/oc/pgm/features/FeatureDefinitionParser.java create mode 100644 PGM/src/main/java/tc/oc/pgm/features/FeatureFactory.java create mode 100644 PGM/src/main/java/tc/oc/pgm/features/FeatureInfo.java create mode 100644 PGM/src/main/java/tc/oc/pgm/features/FeatureManifest.java create mode 100644 PGM/src/main/java/tc/oc/pgm/features/FeatureParser.java create mode 100644 PGM/src/main/java/tc/oc/pgm/features/FeatureProxy.java create mode 100644 PGM/src/main/java/tc/oc/pgm/features/FeatureReference.java create mode 100644 PGM/src/main/java/tc/oc/pgm/features/FeatureTypeLiterals.java create mode 100644 PGM/src/main/java/tc/oc/pgm/features/FeatureValidationContext.java create mode 100644 PGM/src/main/java/tc/oc/pgm/features/Features.java create mode 100644 PGM/src/main/java/tc/oc/pgm/features/GamemodeFeature.java create mode 100644 PGM/src/main/java/tc/oc/pgm/features/LegacyFeatureParser.java create mode 100644 PGM/src/main/java/tc/oc/pgm/features/MagicMethodFeatureParser.java create mode 100644 PGM/src/main/java/tc/oc/pgm/features/MatchFeatureContext.java create mode 100644 PGM/src/main/java/tc/oc/pgm/features/ReflectiveFeatureManifest.java create mode 100644 PGM/src/main/java/tc/oc/pgm/features/ReflectiveFeatureParser.java create mode 100644 PGM/src/main/java/tc/oc/pgm/features/RootFeatureManifest.java create mode 100644 PGM/src/main/java/tc/oc/pgm/features/SluggedFeature.java create mode 100644 PGM/src/main/java/tc/oc/pgm/features/SluggedFeatureDefinition.java create mode 100644 PGM/src/main/java/tc/oc/pgm/ffa/FreeForAllCommands.java create mode 100644 PGM/src/main/java/tc/oc/pgm/ffa/FreeForAllMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/ffa/FreeForAllModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/ffa/FreeForAllOptions.java create mode 100644 PGM/src/main/java/tc/oc/pgm/ffa/Tribute.java create mode 100644 PGM/src/main/java/tc/oc/pgm/ffa/events/MatchResizeEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/Filter.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/FilterDispatcher.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/FilterListener.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/FilterManifest.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/FilterMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/FilterTypeException.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/Filterable.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/Filterables.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/ItemMatcher.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/matcher/CauseFilter.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/matcher/QueryTypeFilter.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/matcher/StaticFilter.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/matcher/TypedFilter.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/matcher/WeakTypedFilter.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/matcher/block/MaterialFilter.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/matcher/block/StructuralLoadFilter.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/matcher/block/VoidFilter.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/matcher/damage/AttackerFilter.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/matcher/damage/DamagerFilter.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/matcher/damage/RelationFilter.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/matcher/damage/VictimFilter.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/matcher/entity/EntityTypeFilter.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/matcher/entity/LegacyWorldFilter.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/matcher/entity/SpawnReasonFilter.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/matcher/match/FlagStateFilter.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/matcher/match/LegacyRandomFilter.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/matcher/match/MatchMutationFilter.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/matcher/match/MatchStateFilter.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/matcher/match/MonostableFilter.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/matcher/match/PlayerCountFilter.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/matcher/match/RandomFilter.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/matcher/party/CompetitorFilter.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/matcher/party/GoalFilter.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/matcher/party/RankFilter.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/matcher/party/ScoreFilter.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/matcher/party/TeamFilter.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/matcher/player/AttributeFilter.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/matcher/player/CanFlyFilter.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/matcher/player/CarryingFlagFilter.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/matcher/player/CarryingItemFilter.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/matcher/player/HoldingItemFilter.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/matcher/player/KillStreakFilter.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/matcher/player/ParticipatingFilter.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/matcher/player/PlayerClassFilter.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/matcher/player/PoseFilter.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/matcher/player/SpawnedPlayerFilter.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/matcher/player/SpawnedPlayerItemFilter.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/matcher/player/WearingItemFilter.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/operator/AggregateFilter.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/operator/AllFilter.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/operator/AnyFilter.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/operator/ChainFilter.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/operator/FallthroughFilter.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/operator/FilterNode.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/operator/InverseFilter.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/operator/MultiFilterFunction.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/operator/OneFilter.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/operator/SameTeamFilter.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/operator/SingleFilterFunction.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/operator/TeamFilterAdapter.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/operator/TransformedFilter.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/parser/DynamicFilterValidation.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/parser/FilterDefinitionParser.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/parser/FilterParser.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/parser/LegacyFilterDefinitionParser.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/parser/LegacyFilterParser.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/parser/RespondsToQueryValidation.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/query/BlockEventQuery.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/query/BlockQuery.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/query/DamageQuery.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/query/EntityQuery.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/query/EntitySpawnQuery.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/query/ForwardingPlayerQuery.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/query/IBlockEventQuery.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/query/IBlockQuery.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/query/IDamageQuery.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/query/IEntityEventQuery.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/query/IEntityQuery.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/query/IEntitySpawnQuery.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/query/IEntityTypeQuery.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/query/IEventQuery.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/query/ILocationQuery.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/query/IMatchQuery.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/query/IMaterialQuery.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/query/IPartyQuery.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/query/IPlayerBlockEventQuery.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/query/IPlayerEventQuery.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/query/IPlayerQuery.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/query/IPoseQuery.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/query/IQuery.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/query/ITransientQuery.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/query/MaterialQuery.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/query/PlayerBlockEventQuery.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/query/PlayerEventQuery.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/query/PlayerQueryWithLocation.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/query/TransientPlayerQuery.java create mode 100644 PGM/src/main/java/tc/oc/pgm/filters/query/TransientQuery.java create mode 100644 PGM/src/main/java/tc/oc/pgm/fireworks/FireworkUtil.java create mode 100644 PGM/src/main/java/tc/oc/pgm/fireworks/FireworksConfig.java create mode 100644 PGM/src/main/java/tc/oc/pgm/fireworks/ObjectivesFireworkListener.java create mode 100644 PGM/src/main/java/tc/oc/pgm/fireworks/PostMatchFireworkListener.java create mode 100644 PGM/src/main/java/tc/oc/pgm/flag/Flag.java create mode 100644 PGM/src/main/java/tc/oc/pgm/flag/FlagDefinition.java create mode 100644 PGM/src/main/java/tc/oc/pgm/flag/FlagManifest.java create mode 100644 PGM/src/main/java/tc/oc/pgm/flag/FlagParser.java create mode 100644 PGM/src/main/java/tc/oc/pgm/flag/Net.java create mode 100644 PGM/src/main/java/tc/oc/pgm/flag/Post.java create mode 100644 PGM/src/main/java/tc/oc/pgm/flag/event/FlagCaptureEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/flag/event/FlagPickupEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/flag/event/FlagStateChangeEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/flag/state/BaseState.java create mode 100644 PGM/src/main/java/tc/oc/pgm/flag/state/Captured.java create mode 100644 PGM/src/main/java/tc/oc/pgm/flag/state/Carried.java create mode 100644 PGM/src/main/java/tc/oc/pgm/flag/state/Completed.java create mode 100644 PGM/src/main/java/tc/oc/pgm/flag/state/Dropped.java create mode 100644 PGM/src/main/java/tc/oc/pgm/flag/state/Missing.java create mode 100644 PGM/src/main/java/tc/oc/pgm/flag/state/Respawning.java create mode 100644 PGM/src/main/java/tc/oc/pgm/flag/state/Returned.java create mode 100644 PGM/src/main/java/tc/oc/pgm/flag/state/Returning.java create mode 100644 PGM/src/main/java/tc/oc/pgm/flag/state/Spawned.java create mode 100644 PGM/src/main/java/tc/oc/pgm/flag/state/State.java create mode 100644 PGM/src/main/java/tc/oc/pgm/flag/state/Uncarried.java create mode 100644 PGM/src/main/java/tc/oc/pgm/freeze/Freeze.java create mode 100644 PGM/src/main/java/tc/oc/pgm/freeze/FreezeCommands.java create mode 100644 PGM/src/main/java/tc/oc/pgm/freeze/FreezeConfig.java create mode 100644 PGM/src/main/java/tc/oc/pgm/freeze/FreezeListener.java create mode 100644 PGM/src/main/java/tc/oc/pgm/gamerules/GameRule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/gamerules/GameRulesMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/gamerules/GameRulesModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/ghostsquadron/GhostSquadron.java create mode 100644 PGM/src/main/java/tc/oc/pgm/ghostsquadron/GhostSquadronMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/ghostsquadron/GhostSquadronModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/ghostsquadron/GhostSquadronTask.java create mode 100644 PGM/src/main/java/tc/oc/pgm/ghostsquadron/RevealTask.java create mode 100644 PGM/src/main/java/tc/oc/pgm/goals/Contribution.java create mode 100644 PGM/src/main/java/tc/oc/pgm/goals/Goal.java create mode 100644 PGM/src/main/java/tc/oc/pgm/goals/GoalCommands.java create mode 100644 PGM/src/main/java/tc/oc/pgm/goals/GoalComponent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/goals/GoalDefinition.java create mode 100644 PGM/src/main/java/tc/oc/pgm/goals/GoalDefinitionImpl.java create mode 100644 PGM/src/main/java/tc/oc/pgm/goals/GoalMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/goals/GoalModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/goals/GoalProgress.java create mode 100644 PGM/src/main/java/tc/oc/pgm/goals/GoalsMatchResult.java create mode 100644 PGM/src/main/java/tc/oc/pgm/goals/GoalsVictoryCondition.java create mode 100644 PGM/src/main/java/tc/oc/pgm/goals/IncrementalGoal.java create mode 100644 PGM/src/main/java/tc/oc/pgm/goals/ModeChangeGoal.java create mode 100644 PGM/src/main/java/tc/oc/pgm/goals/OwnableGoalDefinition.java create mode 100644 PGM/src/main/java/tc/oc/pgm/goals/OwnableGoalDefinitionImpl.java create mode 100644 PGM/src/main/java/tc/oc/pgm/goals/OwnedGoal.java create mode 100644 PGM/src/main/java/tc/oc/pgm/goals/ProximityGoal.java create mode 100644 PGM/src/main/java/tc/oc/pgm/goals/ProximityGoalDefinition.java create mode 100644 PGM/src/main/java/tc/oc/pgm/goals/ProximityGoalDefinitionImpl.java create mode 100644 PGM/src/main/java/tc/oc/pgm/goals/ProximityMetric.java create mode 100644 PGM/src/main/java/tc/oc/pgm/goals/SimpleGoal.java create mode 100644 PGM/src/main/java/tc/oc/pgm/goals/TouchableGoal.java create mode 100644 PGM/src/main/java/tc/oc/pgm/goals/events/GoalCompleteEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/goals/events/GoalEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/goals/events/GoalProximityChangeEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/goals/events/GoalStatusChangeEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/goals/events/GoalTouchEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/hunger/HungerMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/hunger/HungerModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/inventory/InventoryCommands.java create mode 100644 PGM/src/main/java/tc/oc/pgm/inventory/ViewInventoryMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/itemkeep/ItemKeepManifest.java create mode 100644 PGM/src/main/java/tc/oc/pgm/itemkeep/ItemKeepParser.java create mode 100644 PGM/src/main/java/tc/oc/pgm/itemkeep/ItemKeepPlayerFacet.java create mode 100644 PGM/src/main/java/tc/oc/pgm/itemkeep/ItemKeepRules.java create mode 100644 PGM/src/main/java/tc/oc/pgm/itemmeta/ItemModifier.java create mode 100644 PGM/src/main/java/tc/oc/pgm/itemmeta/ItemModifyMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/itemmeta/ItemModifyModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/itemmeta/ItemRule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/join/JoinAllowed.java create mode 100644 PGM/src/main/java/tc/oc/pgm/join/JoinCommands.java create mode 100644 PGM/src/main/java/tc/oc/pgm/join/JoinConfiguration.java create mode 100644 PGM/src/main/java/tc/oc/pgm/join/JoinDenied.java create mode 100644 PGM/src/main/java/tc/oc/pgm/join/JoinHandler.java create mode 100644 PGM/src/main/java/tc/oc/pgm/join/JoinMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/join/JoinMethod.java create mode 100644 PGM/src/main/java/tc/oc/pgm/join/JoinQueued.java create mode 100644 PGM/src/main/java/tc/oc/pgm/join/JoinRequest.java create mode 100644 PGM/src/main/java/tc/oc/pgm/join/JoinResult.java create mode 100644 PGM/src/main/java/tc/oc/pgm/join/QueuedParticipants.java create mode 100644 PGM/src/main/java/tc/oc/pgm/killreward/KillReward.java create mode 100644 PGM/src/main/java/tc/oc/pgm/killreward/KillRewardMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/killreward/KillRewardModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/kits/AttributeKit.java create mode 100644 PGM/src/main/java/tc/oc/pgm/kits/AttributePlayerFacet.java create mode 100644 PGM/src/main/java/tc/oc/pgm/kits/ClearItemsKit.java create mode 100644 PGM/src/main/java/tc/oc/pgm/kits/ClearKit.java create mode 100644 PGM/src/main/java/tc/oc/pgm/kits/ClearKitBase.java create mode 100644 PGM/src/main/java/tc/oc/pgm/kits/DelayedKit.java create mode 100644 PGM/src/main/java/tc/oc/pgm/kits/EliminateKit.java create mode 100644 PGM/src/main/java/tc/oc/pgm/kits/FlyKit.java create mode 100644 PGM/src/main/java/tc/oc/pgm/kits/ForceKit.java create mode 100644 PGM/src/main/java/tc/oc/pgm/kits/FreeItemKit.java create mode 100644 PGM/src/main/java/tc/oc/pgm/kits/GameModeKit.java create mode 100644 PGM/src/main/java/tc/oc/pgm/kits/GlobalItemParser.java create mode 100644 PGM/src/main/java/tc/oc/pgm/kits/GrenadeListener.java create mode 100644 PGM/src/main/java/tc/oc/pgm/kits/HealthKit.java create mode 100644 PGM/src/main/java/tc/oc/pgm/kits/HitboxKit.java create mode 100644 PGM/src/main/java/tc/oc/pgm/kits/HungerKit.java create mode 100644 PGM/src/main/java/tc/oc/pgm/kits/ImpulseKit.java create mode 100644 PGM/src/main/java/tc/oc/pgm/kits/ItemKit.java create mode 100644 PGM/src/main/java/tc/oc/pgm/kits/ItemKitApplicator.java create mode 100644 PGM/src/main/java/tc/oc/pgm/kits/ItemParser.java create mode 100644 PGM/src/main/java/tc/oc/pgm/kits/ItemSharingAndLockingListener.java create mode 100644 PGM/src/main/java/tc/oc/pgm/kits/Kit.java create mode 100644 PGM/src/main/java/tc/oc/pgm/kits/KitDefinitionParser.java create mode 100644 PGM/src/main/java/tc/oc/pgm/kits/KitManifest.java create mode 100644 PGM/src/main/java/tc/oc/pgm/kits/KitNode.java create mode 100644 PGM/src/main/java/tc/oc/pgm/kits/KitParser.java create mode 100644 PGM/src/main/java/tc/oc/pgm/kits/KitPlayerFacet.java create mode 100644 PGM/src/main/java/tc/oc/pgm/kits/KitRule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/kits/KnockbackReductionKit.java create mode 100644 PGM/src/main/java/tc/oc/pgm/kits/MaxHealthKit.java create mode 100644 PGM/src/main/java/tc/oc/pgm/kits/NaturalRegenerationKit.java create mode 100644 PGM/src/main/java/tc/oc/pgm/kits/PotionKit.java create mode 100644 PGM/src/main/java/tc/oc/pgm/kits/RemovableValidation.java create mode 100644 PGM/src/main/java/tc/oc/pgm/kits/RemoveKit.java create mode 100644 PGM/src/main/java/tc/oc/pgm/kits/ResetEnderPearlsKit.java create mode 100644 PGM/src/main/java/tc/oc/pgm/kits/SlotItemKit.java create mode 100644 PGM/src/main/java/tc/oc/pgm/kits/TeamSwitchKit.java create mode 100644 PGM/src/main/java/tc/oc/pgm/kits/WalkSpeedKit.java create mode 100644 PGM/src/main/java/tc/oc/pgm/kits/tag/Grenade.java create mode 100644 PGM/src/main/java/tc/oc/pgm/kits/tag/GrenadeItemTag.java create mode 100644 PGM/src/main/java/tc/oc/pgm/kits/tag/ItemTags.java create mode 100644 PGM/src/main/java/tc/oc/pgm/lane/Lane.java create mode 100644 PGM/src/main/java/tc/oc/pgm/lane/LaneManifest.java create mode 100644 PGM/src/main/java/tc/oc/pgm/lane/LaneMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/listeners/BlockTransformListener.java create mode 100644 PGM/src/main/java/tc/oc/pgm/listeners/FormattingListener.java create mode 100644 PGM/src/main/java/tc/oc/pgm/listeners/ItemTransferListener.java create mode 100644 PGM/src/main/java/tc/oc/pgm/listeners/MatchAnnouncer.java create mode 100644 PGM/src/main/java/tc/oc/pgm/listeners/PGMListener.java create mode 100644 PGM/src/main/java/tc/oc/pgm/listeners/WorldProblemMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/logging/MapFilter.java create mode 100644 PGM/src/main/java/tc/oc/pgm/logging/MapTagger.java create mode 100644 PGM/src/main/java/tc/oc/pgm/loot/Cache.java create mode 100644 PGM/src/main/java/tc/oc/pgm/loot/FillListener.java create mode 100644 PGM/src/main/java/tc/oc/pgm/loot/Filler.java create mode 100644 PGM/src/main/java/tc/oc/pgm/loot/Loot.java create mode 100644 PGM/src/main/java/tc/oc/pgm/loot/LootManifest.java create mode 100644 PGM/src/main/java/tc/oc/pgm/map/Contributor.java create mode 100644 PGM/src/main/java/tc/oc/pgm/map/MapConfiguration.java create mode 100644 PGM/src/main/java/tc/oc/pgm/map/MapDefinition.java create mode 100644 PGM/src/main/java/tc/oc/pgm/map/MapDocument.java create mode 100644 PGM/src/main/java/tc/oc/pgm/map/MapFilePreprocessor.java create mode 100644 PGM/src/main/java/tc/oc/pgm/map/MapFolder.java create mode 100644 PGM/src/main/java/tc/oc/pgm/map/MapId.java create mode 100644 PGM/src/main/java/tc/oc/pgm/map/MapInfo.java create mode 100644 PGM/src/main/java/tc/oc/pgm/map/MapLibrary.java create mode 100644 PGM/src/main/java/tc/oc/pgm/map/MapLibraryImpl.java create mode 100644 PGM/src/main/java/tc/oc/pgm/map/MapLoader.java create mode 100644 PGM/src/main/java/tc/oc/pgm/map/MapLoaderImpl.java create mode 100644 PGM/src/main/java/tc/oc/pgm/map/MapLogRecord.java create mode 100644 PGM/src/main/java/tc/oc/pgm/map/MapLogger.java create mode 100644 PGM/src/main/java/tc/oc/pgm/map/MapModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/map/MapModuleContext.java create mode 100644 PGM/src/main/java/tc/oc/pgm/map/MapModuleFactory.java create mode 100644 PGM/src/main/java/tc/oc/pgm/map/MapModuleManifest.java create mode 100644 PGM/src/main/java/tc/oc/pgm/map/MapModuleParser.java create mode 100644 PGM/src/main/java/tc/oc/pgm/map/MapNotFoundException.java create mode 100644 PGM/src/main/java/tc/oc/pgm/map/MapParserBinder.java create mode 100644 PGM/src/main/java/tc/oc/pgm/map/MapProto.java create mode 100644 PGM/src/main/java/tc/oc/pgm/map/MapRootParser.java create mode 100644 PGM/src/main/java/tc/oc/pgm/map/MapSource.java create mode 100644 PGM/src/main/java/tc/oc/pgm/map/MapmakerPlayerFacet.java create mode 100644 PGM/src/main/java/tc/oc/pgm/map/PGMMap.java create mode 100644 PGM/src/main/java/tc/oc/pgm/map/PGMMapConfiguration.java create mode 100644 PGM/src/main/java/tc/oc/pgm/map/PGMMapEnvironment.java create mode 100644 PGM/src/main/java/tc/oc/pgm/map/ParsingMethod.java create mode 100644 PGM/src/main/java/tc/oc/pgm/map/ParsingProvider.java create mode 100644 PGM/src/main/java/tc/oc/pgm/map/ProtoVersions.java create mode 100644 PGM/src/main/java/tc/oc/pgm/map/ProvisionAtParseTime.java create mode 100644 PGM/src/main/java/tc/oc/pgm/map/RootElementParsingProvider.java create mode 100644 PGM/src/main/java/tc/oc/pgm/map/StaticMethodMapModuleFactory.java create mode 100644 PGM/src/main/java/tc/oc/pgm/map/inject/MapBinders.java create mode 100644 PGM/src/main/java/tc/oc/pgm/map/inject/MapInjectionScope.java create mode 100644 PGM/src/main/java/tc/oc/pgm/map/inject/MapManifest.java create mode 100644 PGM/src/main/java/tc/oc/pgm/map/inject/MapScoped.java create mode 100644 PGM/src/main/java/tc/oc/pgm/mapratings/MapRatingsCommands.java create mode 100644 PGM/src/main/java/tc/oc/pgm/mapratings/MapRatingsConfiguration.java create mode 100644 PGM/src/main/java/tc/oc/pgm/mapratings/MapRatingsMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/match/Competitor.java create mode 100644 PGM/src/main/java/tc/oc/pgm/match/FixtureMatchModuleFactory.java create mode 100644 PGM/src/main/java/tc/oc/pgm/match/Match.java create mode 100644 PGM/src/main/java/tc/oc/pgm/match/MatchAudiences.java create mode 100644 PGM/src/main/java/tc/oc/pgm/match/MatchCounter.java create mode 100644 PGM/src/main/java/tc/oc/pgm/match/MatchEntityState.java create mode 100644 PGM/src/main/java/tc/oc/pgm/match/MatchEventRegistry.java create mode 100644 PGM/src/main/java/tc/oc/pgm/match/MatchExecutor.java create mode 100644 PGM/src/main/java/tc/oc/pgm/match/MatchFacetContext.java create mode 100644 PGM/src/main/java/tc/oc/pgm/match/MatchFacetContextManifest.java create mode 100644 PGM/src/main/java/tc/oc/pgm/match/MatchFinder.java create mode 100644 PGM/src/main/java/tc/oc/pgm/match/MatchFormatter.java create mode 100644 PGM/src/main/java/tc/oc/pgm/match/MatchImpl.java create mode 100644 PGM/src/main/java/tc/oc/pgm/match/MatchInjectionScope.java create mode 100644 PGM/src/main/java/tc/oc/pgm/match/MatchListenerMeta.java create mode 100644 PGM/src/main/java/tc/oc/pgm/match/MatchLoader.java create mode 100644 PGM/src/main/java/tc/oc/pgm/match/MatchManager.java create mode 100644 PGM/src/main/java/tc/oc/pgm/match/MatchManifest.java create mode 100644 PGM/src/main/java/tc/oc/pgm/match/MatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/match/MatchModuleContext.java create mode 100644 PGM/src/main/java/tc/oc/pgm/match/MatchModuleFactory.java create mode 100644 PGM/src/main/java/tc/oc/pgm/match/MatchPlayer.java create mode 100644 PGM/src/main/java/tc/oc/pgm/match/MatchPlayerEventRouter.java create mode 100644 PGM/src/main/java/tc/oc/pgm/match/MatchPlayerExecutor.java create mode 100644 PGM/src/main/java/tc/oc/pgm/match/MatchPlayerFacet.java create mode 100644 PGM/src/main/java/tc/oc/pgm/match/MatchPlayerFacetBinder.java create mode 100644 PGM/src/main/java/tc/oc/pgm/match/MatchPlayerFacetManifest.java create mode 100644 PGM/src/main/java/tc/oc/pgm/match/MatchPlayerFinder.java create mode 100644 PGM/src/main/java/tc/oc/pgm/match/MatchPlayerManifest.java create mode 100644 PGM/src/main/java/tc/oc/pgm/match/MatchPlayerState.java create mode 100644 PGM/src/main/java/tc/oc/pgm/match/MatchRealtimeScheduler.java create mode 100644 PGM/src/main/java/tc/oc/pgm/match/MatchScheduler.java create mode 100644 PGM/src/main/java/tc/oc/pgm/match/MatchScope.java create mode 100644 PGM/src/main/java/tc/oc/pgm/match/MatchState.java create mode 100644 PGM/src/main/java/tc/oc/pgm/match/MatchUserContext.java create mode 100644 PGM/src/main/java/tc/oc/pgm/match/MatchUserFacet.java create mode 100644 PGM/src/main/java/tc/oc/pgm/match/MatchUserFacetBinder.java create mode 100644 PGM/src/main/java/tc/oc/pgm/match/MatchUserManifest.java create mode 100644 PGM/src/main/java/tc/oc/pgm/match/Matches.java create mode 100644 PGM/src/main/java/tc/oc/pgm/match/MultiPlayerParty.java create mode 100644 PGM/src/main/java/tc/oc/pgm/match/Observers.java create mode 100644 PGM/src/main/java/tc/oc/pgm/match/ObservingParty.java create mode 100644 PGM/src/main/java/tc/oc/pgm/match/ParticipantState.java create mode 100644 PGM/src/main/java/tc/oc/pgm/match/Parties.java create mode 100644 PGM/src/main/java/tc/oc/pgm/match/Party.java create mode 100644 PGM/src/main/java/tc/oc/pgm/match/PlayerRelation.java create mode 100644 PGM/src/main/java/tc/oc/pgm/match/Repeatable.java create mode 100644 PGM/src/main/java/tc/oc/pgm/match/inject/ForMatch.java create mode 100644 PGM/src/main/java/tc/oc/pgm/match/inject/ForMatchUser.java create mode 100644 PGM/src/main/java/tc/oc/pgm/match/inject/ForRunningMatch.java create mode 100644 PGM/src/main/java/tc/oc/pgm/match/inject/MatchBinders.java create mode 100644 PGM/src/main/java/tc/oc/pgm/match/inject/MatchModuleFactoryManifest.java create mode 100644 PGM/src/main/java/tc/oc/pgm/match/inject/MatchModuleFeatureManifest.java create mode 100644 PGM/src/main/java/tc/oc/pgm/match/inject/MatchModuleFixtureManifest.java create mode 100644 PGM/src/main/java/tc/oc/pgm/match/inject/MatchModuleManifest.java create mode 100644 PGM/src/main/java/tc/oc/pgm/match/inject/MatchScoped.java create mode 100644 PGM/src/main/java/tc/oc/pgm/modes/ObjectiveMode.java create mode 100644 PGM/src/main/java/tc/oc/pgm/modes/ObjectiveModeCommands.java create mode 100644 PGM/src/main/java/tc/oc/pgm/modes/ObjectiveModeManager.java create mode 100644 PGM/src/main/java/tc/oc/pgm/modes/ObjectiveModeManifest.java create mode 100644 PGM/src/main/java/tc/oc/pgm/module/MatchModulesManifest.java create mode 100644 PGM/src/main/java/tc/oc/pgm/module/ModuleContext.java create mode 100644 PGM/src/main/java/tc/oc/pgm/module/ModuleDependencyTransformer.java create mode 100644 PGM/src/main/java/tc/oc/pgm/module/ModuleDescription.java create mode 100644 PGM/src/main/java/tc/oc/pgm/module/ModuleExceptionHandler.java create mode 100644 PGM/src/main/java/tc/oc/pgm/module/ModuleLoadException.java create mode 100644 PGM/src/main/java/tc/oc/pgm/module/ModuleManifest.java create mode 100644 PGM/src/main/java/tc/oc/pgm/module/ModuleSource.java create mode 100644 PGM/src/main/java/tc/oc/pgm/module/ProvisionResult.java create mode 100644 PGM/src/main/java/tc/oc/pgm/module/ProvisionWrapper.java create mode 100644 PGM/src/main/java/tc/oc/pgm/module/UpstreamProvisionFailure.java create mode 100644 PGM/src/main/java/tc/oc/pgm/modules/ArrowRemovalMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/modules/DiscardPotionBottlesMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/modules/DiscardPotionBottlesModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/modules/EventFilterMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/modules/FriendlyFireRefundMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/modules/FriendlyFireRefundModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/modules/InfoModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/modules/InternalMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/modules/InternalModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/modules/ItemDestroyMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/modules/ItemDestroyModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/modules/MaxBuildHeightMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/modules/MaxBuildHeightModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/modules/MobsMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/modules/MobsModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/modules/ModifyBowProjectileMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/modules/ModifyBowProjectileModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/modules/MultiTradeMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/modules/PlayableRegionMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/modules/PlayableRegionModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/modules/TimeLockModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/modules/ToolRepairMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/modules/ToolRepairModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/mutation/Mutation.java create mode 100644 PGM/src/main/java/tc/oc/pgm/mutation/MutationMapModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/mutation/MutationMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/mutation/MutationOptions.java create mode 100644 PGM/src/main/java/tc/oc/pgm/mutation/MutationQueue.java create mode 100644 PGM/src/main/java/tc/oc/pgm/mutation/command/MutationCommands.java create mode 100644 PGM/src/main/java/tc/oc/pgm/mutation/submodule/KitMutationModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/mutation/submodule/MutationModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/mutation/submodule/MutationModules.java create mode 100644 PGM/src/main/java/tc/oc/pgm/mutation/submodule/TargetableMutationModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/physics/AccelerationPlayerFacet.java create mode 100644 PGM/src/main/java/tc/oc/pgm/physics/DebugVelocityPlayerFacet.java create mode 100644 PGM/src/main/java/tc/oc/pgm/physics/KnockbackParser.java create mode 100644 PGM/src/main/java/tc/oc/pgm/physics/KnockbackPlayerFacet.java create mode 100644 PGM/src/main/java/tc/oc/pgm/physics/KnockbackSettings.java create mode 100644 PGM/src/main/java/tc/oc/pgm/physics/PlayerForce.java create mode 100644 PGM/src/main/java/tc/oc/pgm/physics/PlayerPhysicsManifest.java create mode 100644 PGM/src/main/java/tc/oc/pgm/physics/RelativeFlags.java create mode 100644 PGM/src/main/java/tc/oc/pgm/picker/PickerManifest.java create mode 100644 PGM/src/main/java/tc/oc/pgm/picker/PickerMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/picker/PickerSettings.java create mode 100644 PGM/src/main/java/tc/oc/pgm/pickup/Pickup.java create mode 100644 PGM/src/main/java/tc/oc/pgm/pickup/PickupDefinition.java create mode 100644 PGM/src/main/java/tc/oc/pgm/pickup/PickupModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/playerstats/StatSettings.java create mode 100644 PGM/src/main/java/tc/oc/pgm/playerstats/StatsManifest.java create mode 100644 PGM/src/main/java/tc/oc/pgm/playerstats/StatsPlayerFacet.java create mode 100644 PGM/src/main/java/tc/oc/pgm/playerstats/StatsUserFacet.java create mode 100644 PGM/src/main/java/tc/oc/pgm/points/AggregatePointProvider.java create mode 100644 PGM/src/main/java/tc/oc/pgm/points/AngleProvider.java create mode 100644 PGM/src/main/java/tc/oc/pgm/points/DirectedPitchProvider.java create mode 100644 PGM/src/main/java/tc/oc/pgm/points/DirectedYawProvider.java create mode 100644 PGM/src/main/java/tc/oc/pgm/points/PointParser.java create mode 100644 PGM/src/main/java/tc/oc/pgm/points/PointProvider.java create mode 100644 PGM/src/main/java/tc/oc/pgm/points/PointProviderAttributes.java create mode 100644 PGM/src/main/java/tc/oc/pgm/points/PointProviderLocation.java create mode 100644 PGM/src/main/java/tc/oc/pgm/points/RandomPointProvider.java create mode 100644 PGM/src/main/java/tc/oc/pgm/points/RegionPointProvider.java create mode 100644 PGM/src/main/java/tc/oc/pgm/points/SequentialPointProvider.java create mode 100644 PGM/src/main/java/tc/oc/pgm/points/SpreadPointProvider.java create mode 100644 PGM/src/main/java/tc/oc/pgm/points/StaticAngleProvider.java create mode 100644 PGM/src/main/java/tc/oc/pgm/polls/Poll.java create mode 100644 PGM/src/main/java/tc/oc/pgm/polls/PollEndEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/polls/PollEndReason.java create mode 100644 PGM/src/main/java/tc/oc/pgm/polls/PollEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/polls/PollKick.java create mode 100644 PGM/src/main/java/tc/oc/pgm/polls/PollListener.java create mode 100644 PGM/src/main/java/tc/oc/pgm/polls/PollManager.java create mode 100644 PGM/src/main/java/tc/oc/pgm/polls/PollNextMap.java create mode 100644 PGM/src/main/java/tc/oc/pgm/polls/PollStartEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/portals/DoubleTransform.java create mode 100644 PGM/src/main/java/tc/oc/pgm/portals/InvertibleOperator.java create mode 100644 PGM/src/main/java/tc/oc/pgm/portals/Portal.java create mode 100644 PGM/src/main/java/tc/oc/pgm/portals/PortalExitRegion.java create mode 100644 PGM/src/main/java/tc/oc/pgm/portals/PortalModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/portals/PortalPlayerFacet.java create mode 100644 PGM/src/main/java/tc/oc/pgm/portals/PortalTransform.java create mode 100644 PGM/src/main/java/tc/oc/pgm/projectile/ClickAction.java create mode 100644 PGM/src/main/java/tc/oc/pgm/projectile/EntityLaunchEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/projectile/ProjectileDefinition.java create mode 100644 PGM/src/main/java/tc/oc/pgm/projectile/ProjectileMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/projectile/ProjectileModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/projectile/ProjectilePlayerFacet.java create mode 100644 PGM/src/main/java/tc/oc/pgm/projectile/Projectiles.java create mode 100644 PGM/src/main/java/tc/oc/pgm/proximity/ProximityAlarm.java create mode 100644 PGM/src/main/java/tc/oc/pgm/proximity/ProximityAlarmDefinition.java create mode 100644 PGM/src/main/java/tc/oc/pgm/proximity/ProximityAlarmMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/proximity/ProximityAlarmModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/quota/Quota.java create mode 100644 PGM/src/main/java/tc/oc/pgm/quota/QuotaConfig.java create mode 100644 PGM/src/main/java/tc/oc/pgm/quota/QuotaMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/rage/RageMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/rage/RageModule.java create mode 100755 PGM/src/main/java/tc/oc/pgm/raindrops/RaindropListener.java create mode 100644 PGM/src/main/java/tc/oc/pgm/raindrops/RaindropManifest.java create mode 100644 PGM/src/main/java/tc/oc/pgm/regions/BlockBoundedValidation.java create mode 100644 PGM/src/main/java/tc/oc/pgm/regions/BlockRegion.java create mode 100644 PGM/src/main/java/tc/oc/pgm/regions/Complement.java create mode 100644 PGM/src/main/java/tc/oc/pgm/regions/CompoundRegion.java create mode 100644 PGM/src/main/java/tc/oc/pgm/regions/CuboidRegion.java create mode 100644 PGM/src/main/java/tc/oc/pgm/regions/CuboidValidation.java create mode 100644 PGM/src/main/java/tc/oc/pgm/regions/CylindricalRegion.java create mode 100644 PGM/src/main/java/tc/oc/pgm/regions/EmptyRegion.java create mode 100644 PGM/src/main/java/tc/oc/pgm/regions/EverywhereRegion.java create mode 100644 PGM/src/main/java/tc/oc/pgm/regions/FiniteBlockRegion.java create mode 100644 PGM/src/main/java/tc/oc/pgm/regions/HalfspaceRegion.java create mode 100644 PGM/src/main/java/tc/oc/pgm/regions/Intersection.java create mode 100644 PGM/src/main/java/tc/oc/pgm/regions/MirroredRegion.java create mode 100644 PGM/src/main/java/tc/oc/pgm/regions/NegativeRegion.java create mode 100644 PGM/src/main/java/tc/oc/pgm/regions/PointRegion.java create mode 100644 PGM/src/main/java/tc/oc/pgm/regions/RandomPointsValidation.java create mode 100644 PGM/src/main/java/tc/oc/pgm/regions/Region.java create mode 100644 PGM/src/main/java/tc/oc/pgm/regions/RegionDefinitionParser.java create mode 100644 PGM/src/main/java/tc/oc/pgm/regions/RegionManifest.java create mode 100644 PGM/src/main/java/tc/oc/pgm/regions/RegionParser.java create mode 100644 PGM/src/main/java/tc/oc/pgm/regions/Regions.java create mode 100644 PGM/src/main/java/tc/oc/pgm/regions/SectorRegion.java create mode 100644 PGM/src/main/java/tc/oc/pgm/regions/SphereRegion.java create mode 100644 PGM/src/main/java/tc/oc/pgm/regions/TransformedRegion.java create mode 100644 PGM/src/main/java/tc/oc/pgm/regions/TranslatedRegion.java create mode 100644 PGM/src/main/java/tc/oc/pgm/regions/Union.java create mode 100644 PGM/src/main/java/tc/oc/pgm/renewable/BlockImage.java create mode 100644 PGM/src/main/java/tc/oc/pgm/renewable/BlockRenewEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/renewable/Renewable.java create mode 100644 PGM/src/main/java/tc/oc/pgm/renewable/RenewableDefinition.java create mode 100644 PGM/src/main/java/tc/oc/pgm/renewable/RenewableMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/renewable/RenewableModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/respack/ResourcePackMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/restart/AutoRestartConfiguration.java create mode 100644 PGM/src/main/java/tc/oc/pgm/restart/RestartListener.java create mode 100644 PGM/src/main/java/tc/oc/pgm/rotation/AbstractRotationProvider.java create mode 100644 PGM/src/main/java/tc/oc/pgm/rotation/AppendTransformation.java create mode 100644 PGM/src/main/java/tc/oc/pgm/rotation/FileRotationProvider.java create mode 100644 PGM/src/main/java/tc/oc/pgm/rotation/FileRotationProviderFactory.java create mode 100644 PGM/src/main/java/tc/oc/pgm/rotation/InsertTransformation.java create mode 100644 PGM/src/main/java/tc/oc/pgm/rotation/RemoveAllTransformation.java create mode 100644 PGM/src/main/java/tc/oc/pgm/rotation/RemoveIndexTransformation.java create mode 100644 PGM/src/main/java/tc/oc/pgm/rotation/RotationManager.java create mode 100644 PGM/src/main/java/tc/oc/pgm/rotation/RotationProvider.java create mode 100644 PGM/src/main/java/tc/oc/pgm/rotation/RotationProviderInfo.java create mode 100644 PGM/src/main/java/tc/oc/pgm/rotation/RotationState.java create mode 100644 PGM/src/main/java/tc/oc/pgm/rotation/RotationTransformation.java create mode 100644 PGM/src/main/java/tc/oc/pgm/score/ScoreBoxFactory.java create mode 100644 PGM/src/main/java/tc/oc/pgm/score/ScoreConfig.java create mode 100644 PGM/src/main/java/tc/oc/pgm/score/ScoreMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/score/ScoreMatchResult.java create mode 100644 PGM/src/main/java/tc/oc/pgm/score/ScoreModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/score/ScoreVictoryCondition.java create mode 100644 PGM/src/main/java/tc/oc/pgm/scoreboard/ScoreboardManifest.java create mode 100644 PGM/src/main/java/tc/oc/pgm/scoreboard/ScoreboardMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/scoreboard/ScoreboardSettings.java create mode 100644 PGM/src/main/java/tc/oc/pgm/scoreboard/SidebarMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/scoreboard/SidebarModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/settings/ObserverSetting.java create mode 100644 PGM/src/main/java/tc/oc/pgm/settings/ObserversCallback.java create mode 100644 PGM/src/main/java/tc/oc/pgm/settings/Settings.java create mode 100644 PGM/src/main/java/tc/oc/pgm/shield/ShieldKit.java create mode 100644 PGM/src/main/java/tc/oc/pgm/shield/ShieldMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/shield/ShieldParameters.java create mode 100644 PGM/src/main/java/tc/oc/pgm/shield/ShieldPlayerModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/skillreq/SkillRequirementMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/snapshot/SnapshotMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/spawns/ObserverToolFactory.java create mode 100644 PGM/src/main/java/tc/oc/pgm/spawns/RespawnOptions.java create mode 100644 PGM/src/main/java/tc/oc/pgm/spawns/Spawn.java create mode 100644 PGM/src/main/java/tc/oc/pgm/spawns/SpawnAttributes.java create mode 100644 PGM/src/main/java/tc/oc/pgm/spawns/SpawnMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/spawns/SpawnModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/spawns/SpawnParser.java create mode 100644 PGM/src/main/java/tc/oc/pgm/spawns/events/DeathKitApplyEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/spawns/events/ParticipantDespawnEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/spawns/events/ParticipantReleaseEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/spawns/events/ParticipantSpawnEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/spawns/events/PlayerSpawnEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/spawns/states/Alive.java create mode 100644 PGM/src/main/java/tc/oc/pgm/spawns/states/Dead.java create mode 100644 PGM/src/main/java/tc/oc/pgm/spawns/states/Joining.java create mode 100644 PGM/src/main/java/tc/oc/pgm/spawns/states/Observing.java create mode 100644 PGM/src/main/java/tc/oc/pgm/spawns/states/Participating.java create mode 100644 PGM/src/main/java/tc/oc/pgm/spawns/states/Spawning.java create mode 100644 PGM/src/main/java/tc/oc/pgm/spawns/states/State.java create mode 100644 PGM/src/main/java/tc/oc/pgm/stamina/PlayerStaminaState.java create mode 100644 PGM/src/main/java/tc/oc/pgm/stamina/StaminaMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/stamina/StaminaModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/stamina/StaminaOptions.java create mode 100644 PGM/src/main/java/tc/oc/pgm/stamina/mutators/SimpleMutator.java create mode 100644 PGM/src/main/java/tc/oc/pgm/stamina/mutators/StaminaMutator.java create mode 100644 PGM/src/main/java/tc/oc/pgm/stamina/symptoms/ArcherySymptom.java create mode 100644 PGM/src/main/java/tc/oc/pgm/stamina/symptoms/MeleeSymptom.java create mode 100644 PGM/src/main/java/tc/oc/pgm/stamina/symptoms/PotionSymptom.java create mode 100644 PGM/src/main/java/tc/oc/pgm/stamina/symptoms/StaminaSymptom.java create mode 100644 PGM/src/main/java/tc/oc/pgm/start/HuddleCountdown.java create mode 100644 PGM/src/main/java/tc/oc/pgm/start/PreMatchCountdown.java create mode 100644 PGM/src/main/java/tc/oc/pgm/start/StartCommands.java create mode 100644 PGM/src/main/java/tc/oc/pgm/start/StartConfig.java create mode 100644 PGM/src/main/java/tc/oc/pgm/start/StartCountdown.java create mode 100644 PGM/src/main/java/tc/oc/pgm/start/StartMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/start/UnreadyReason.java create mode 100644 PGM/src/main/java/tc/oc/pgm/stats/DeathPublishingMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/stats/ObjectivePublishingMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/stats/StatisticsConfiguration.java create mode 100644 PGM/src/main/java/tc/oc/pgm/structure/Dynamic.java create mode 100644 PGM/src/main/java/tc/oc/pgm/structure/DynamicDefinition.java create mode 100644 PGM/src/main/java/tc/oc/pgm/structure/DynamicScheduler.java create mode 100644 PGM/src/main/java/tc/oc/pgm/structure/Structure.java create mode 100644 PGM/src/main/java/tc/oc/pgm/structure/StructureDefinition.java create mode 100644 PGM/src/main/java/tc/oc/pgm/structure/StructureManifest.java create mode 100644 PGM/src/main/java/tc/oc/pgm/structure/StructureParser.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tablist/FreeForAllTabEntry.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tablist/MapTabEntry.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tablist/MatchFooterTabEntry.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tablist/MatchPlayerOrder.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tablist/MatchTabManager.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tablist/MatchTabView.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tablist/TeamOrder.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tablist/TeamTabEntry.java create mode 100644 PGM/src/main/java/tc/oc/pgm/teams/JoinTeam.java create mode 100644 PGM/src/main/java/tc/oc/pgm/teams/Team.java create mode 100644 PGM/src/main/java/tc/oc/pgm/teams/TeamCommandUtils.java create mode 100644 PGM/src/main/java/tc/oc/pgm/teams/TeamCommands.java create mode 100644 PGM/src/main/java/tc/oc/pgm/teams/TeamConfiguration.java create mode 100644 PGM/src/main/java/tc/oc/pgm/teams/TeamFactory.java create mode 100644 PGM/src/main/java/tc/oc/pgm/teams/TeamManifest.java create mode 100644 PGM/src/main/java/tc/oc/pgm/teams/TeamMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/teams/TeamModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/teams/TeamParser.java create mode 100644 PGM/src/main/java/tc/oc/pgm/teams/TeamResult.java create mode 100644 PGM/src/main/java/tc/oc/pgm/teams/Teams.java create mode 100644 PGM/src/main/java/tc/oc/pgm/teams/events/TeamResizeEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/teams/events/TeamRespawnsChangeEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/terrain/BlockPhysicsListener.java create mode 100644 PGM/src/main/java/tc/oc/pgm/terrain/DisableKeepSpawnInMemoryListener.java create mode 100644 PGM/src/main/java/tc/oc/pgm/terrain/TerrainManifest.java create mode 100644 PGM/src/main/java/tc/oc/pgm/terrain/TerrainOptions.java create mode 100644 PGM/src/main/java/tc/oc/pgm/terrain/TerrainParser.java create mode 100644 PGM/src/main/java/tc/oc/pgm/terrain/WorldConfigurator.java create mode 100644 PGM/src/main/java/tc/oc/pgm/terrain/WorldConfiguratorBinder.java create mode 100644 PGM/src/main/java/tc/oc/pgm/terrain/WorldManager.java create mode 100644 PGM/src/main/java/tc/oc/pgm/terrain/WorldManagerImpl.java create mode 100644 PGM/src/main/java/tc/oc/pgm/time/TickClock.java create mode 100644 PGM/src/main/java/tc/oc/pgm/time/TickTime.java create mode 100644 PGM/src/main/java/tc/oc/pgm/time/WorldTickClock.java create mode 100644 PGM/src/main/java/tc/oc/pgm/timelimit/TimeLimit.java create mode 100644 PGM/src/main/java/tc/oc/pgm/timelimit/TimeLimitCommands.java create mode 100644 PGM/src/main/java/tc/oc/pgm/timelimit/TimeLimitCountdown.java create mode 100644 PGM/src/main/java/tc/oc/pgm/timelimit/TimeLimitDefinition.java create mode 100644 PGM/src/main/java/tc/oc/pgm/timelimit/TimeLimitMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/timelimit/TimeLimitModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tnt/InstantTNTPlaceEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tnt/TNTManifest.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tnt/TNTMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tnt/TNTParser.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tnt/TNTProperties.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tnt/license/LicenseAccessPlayerFacet.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tnt/license/LicenseBroker.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tnt/license/LicenseCommands.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tnt/license/LicenseConfiguration.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tnt/license/LicenseMonitor.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tnt/license/LicenseMonitorUserFacet.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tnt/license/LicenseRevokeEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tracker/BlockResolver.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tracker/EntityResolver.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tracker/EventResolver.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tracker/MasterResolver.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tracker/ProjectileResolver.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tracker/TrackerManifest.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tracker/damage/AnvilInfo.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tracker/damage/BlockInfo.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tracker/damage/CauseInfo.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tracker/damage/DamageInfo.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tracker/damage/DispenserInfo.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tracker/damage/EntityInfo.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tracker/damage/ExplosionInfo.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tracker/damage/FallInfo.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tracker/damage/FallState.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tracker/damage/FallingBlockInfo.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tracker/damage/FireInfo.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tracker/damage/GenericDamageInfo.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tracker/damage/GenericFallInfo.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tracker/damage/GenericPotionInfo.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tracker/damage/ItemInfo.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tracker/damage/MeleeInfo.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tracker/damage/MobInfo.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tracker/damage/NullDamageInfo.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tracker/damage/OwnerInfo.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tracker/damage/OwnerInfoBase.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tracker/damage/PhysicalInfo.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tracker/damage/PlayerInfo.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tracker/damage/PotionInfo.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tracker/damage/ProjectileInfo.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tracker/damage/RangedInfo.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tracker/damage/SpleefInfo.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tracker/damage/TNTInfo.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tracker/damage/ThrownPotionInfo.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tracker/damage/TrackerInfo.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tracker/event/PlayerSpleefEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tracker/resolvers/DamageResolver.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tracker/resolvers/ExplosionDamageResolver.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tracker/resolvers/FallingBlockDamageResolver.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tracker/resolvers/GenericDamageResolver.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tracker/resolvers/PotionDamageResolver.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tracker/trackers/AbstractTracker.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tracker/trackers/AnvilTracker.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tracker/trackers/BlockTracker.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tracker/trackers/CombatLogTracker.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tracker/trackers/DeathTracker.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tracker/trackers/DispenserTracker.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tracker/trackers/EntityTracker.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tracker/trackers/FallTracker.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tracker/trackers/FireTracker.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tracker/trackers/OwnedMobTracker.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tracker/trackers/ProjectileTracker.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tracker/trackers/SpleefTracker.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tracker/trackers/TNTTracker.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tutorial/Tutorial.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tutorial/TutorialManifest.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tutorial/TutorialParser.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tutorial/TutorialPlayerFacet.java create mode 100644 PGM/src/main/java/tc/oc/pgm/tutorial/TutorialStage.java create mode 100644 PGM/src/main/java/tc/oc/pgm/utils/AllMaterialMatcher.java create mode 100644 PGM/src/main/java/tc/oc/pgm/utils/BlockMaterialMatcher.java create mode 100644 PGM/src/main/java/tc/oc/pgm/utils/CompoundMaterialMatcher.java create mode 100644 PGM/src/main/java/tc/oc/pgm/utils/EntityUtils.java create mode 100644 PGM/src/main/java/tc/oc/pgm/utils/Locations.java create mode 100644 PGM/src/main/java/tc/oc/pgm/utils/MatchPlayers.java create mode 100644 PGM/src/main/java/tc/oc/pgm/utils/MaterialMatcher.java create mode 100644 PGM/src/main/java/tc/oc/pgm/utils/MaterialPattern.java create mode 100644 PGM/src/main/java/tc/oc/pgm/utils/MethodParser.java create mode 100644 PGM/src/main/java/tc/oc/pgm/utils/MethodParserMap.java create mode 100644 PGM/src/main/java/tc/oc/pgm/utils/NoMaterialMatcher.java create mode 100644 PGM/src/main/java/tc/oc/pgm/utils/NumericModifier.java create mode 100644 PGM/src/main/java/tc/oc/pgm/utils/RollingAverageFilter.java create mode 100644 PGM/src/main/java/tc/oc/pgm/utils/Strings.java create mode 100644 PGM/src/main/java/tc/oc/pgm/utils/WorldTickRandom.java create mode 100644 PGM/src/main/java/tc/oc/pgm/utils/XMLUtils.java create mode 100644 PGM/src/main/java/tc/oc/pgm/victory/AbstractVictoryCondition.java create mode 100644 PGM/src/main/java/tc/oc/pgm/victory/CompetitorResult.java create mode 100644 PGM/src/main/java/tc/oc/pgm/victory/DefaultResult.java create mode 100644 PGM/src/main/java/tc/oc/pgm/victory/ImmediateVictoryCondition.java create mode 100644 PGM/src/main/java/tc/oc/pgm/victory/MatchResult.java create mode 100644 PGM/src/main/java/tc/oc/pgm/victory/RankingsChangeEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/victory/TieResult.java create mode 100644 PGM/src/main/java/tc/oc/pgm/victory/VictoryCalculator.java create mode 100644 PGM/src/main/java/tc/oc/pgm/victory/VictoryCondition.java create mode 100644 PGM/src/main/java/tc/oc/pgm/victory/VictoryMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/victory/VictoryResultParser.java create mode 100644 PGM/src/main/java/tc/oc/pgm/wool/MonumentWool.java create mode 100644 PGM/src/main/java/tc/oc/pgm/wool/MonumentWoolFactory.java create mode 100644 PGM/src/main/java/tc/oc/pgm/wool/PlayerWoolPlaceEvent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/wool/WoolManifest.java create mode 100644 PGM/src/main/java/tc/oc/pgm/wool/WoolMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/wool/WoolParser.java create mode 100644 PGM/src/main/java/tc/oc/pgm/worldborder/WorldBorder.java create mode 100644 PGM/src/main/java/tc/oc/pgm/worldborder/WorldBorderMatchModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/worldborder/WorldBorderModule.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/BoundedElement.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/BoundedJDOMFactory.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/BoundedSAXHandler.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/ClonedElement.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/ElementFlattener.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/InheritingElement.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/InvalidXMLException.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/Node.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/NodeSplitter.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/Parseable.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/UnrecognizedXMLException.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/UnsupportedMapProtocolException.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/finder/AllChildren.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/finder/Attributes.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/finder/EmptyChildren.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/finder/Grandchildren.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/finder/NamedChildren.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/finder/NodeFinder.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/finder/Parent.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/finder/ParentText.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/parser/Aggregator.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/parser/AttributeParser.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/parser/BooleanParser.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/parser/DurationParser.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/parser/ElementListParser.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/parser/ElementParser.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/parser/EnumParser.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/parser/EnumParserManifest.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/parser/EnumPropertyManifest.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/parser/MaterialDataParser.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/parser/MaterialParser.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/parser/MessageTemplateParser.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/parser/NumberParser.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/parser/Parser.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/parser/ParserBinders.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/parser/ParserManifest.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/parser/ParserTypeLiterals.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/parser/PercentageParser.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/parser/PrimitiveParser.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/parser/PropertyParser.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/parser/RangeParser.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/parser/RangeParserManifest.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/parser/ReflectiveParser.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/parser/ReflectiveParserManifest.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/parser/StringParser.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/parser/TeamRelationParser.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/parser/TransfiniteParser.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/parser/VectorParser.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/property/ComparableProperty.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/property/DurationProperty.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/property/MessageTemplateProperty.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/property/NumberProperty.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/property/PercentagePropertyFactory.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/property/PropertyBuilder.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/property/PropertyBuilderFactory.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/property/PropertyManifest.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/property/RangeProperty.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/property/TransfiniteProperty.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/validate/DurationIs.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/validate/LocatedValidation.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/validate/MaterialDataIs.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/validate/MaterialIs.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/validate/MessageTemplateIsLocalized.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/validate/NonBlank.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/validate/Validatable.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/validate/Validation.java create mode 100644 PGM/src/main/java/tc/oc/pgm/xml/validate/ValidationContext.java create mode 100644 PGM/src/main/resources/config.yml create mode 100644 PGM/src/main/resources/plugin.yml create mode 100644 PGM/src/test/java/tc/oc/pgm/filter/ItemMatcherTest.java create mode 100644 PGM/src/test/java/tc/oc/pgm/mutation/MutationTest.java create mode 100644 README.md create mode 100644 Tourney/pom.xml create mode 100644 Tourney/src/net/anxuiz/tourney/ClassificationManager.java create mode 100644 Tourney/src/net/anxuiz/tourney/Config.java create mode 100644 Tourney/src/net/anxuiz/tourney/KDMSession.java create mode 100644 Tourney/src/net/anxuiz/tourney/MapClassification.java create mode 100644 Tourney/src/net/anxuiz/tourney/MatchManager.java create mode 100644 Tourney/src/net/anxuiz/tourney/ReadyManager.java create mode 100644 Tourney/src/net/anxuiz/tourney/TeamManager.java create mode 100644 Tourney/src/net/anxuiz/tourney/Tourney.java create mode 100644 Tourney/src/net/anxuiz/tourney/TourneyManifest.java create mode 100644 Tourney/src/net/anxuiz/tourney/TourneyPermissions.java create mode 100644 Tourney/src/net/anxuiz/tourney/TourneyState.java create mode 100644 Tourney/src/net/anxuiz/tourney/command/MapSelectionCommands.java create mode 100644 Tourney/src/net/anxuiz/tourney/command/TeamCommands.java create mode 100644 Tourney/src/net/anxuiz/tourney/command/TourneyCommands.java create mode 100644 Tourney/src/net/anxuiz/tourney/event/EntrantRegisterEvent.java create mode 100644 Tourney/src/net/anxuiz/tourney/event/EntrantUnregisterEvent.java create mode 100644 Tourney/src/net/anxuiz/tourney/event/PartyReadyStatusChangeEvent.java create mode 100644 Tourney/src/net/anxuiz/tourney/event/TourneyStateChangeEvent.java create mode 100644 Tourney/src/net/anxuiz/tourney/event/mapselect/MapSelectionBeginEvent.java create mode 100644 Tourney/src/net/anxuiz/tourney/event/mapselect/MapSelectionClassificationSelectEvent.java create mode 100644 Tourney/src/net/anxuiz/tourney/event/mapselect/MapSelectionClassificationVetoEvent.java create mode 100644 Tourney/src/net/anxuiz/tourney/event/mapselect/MapSelectionEvent.java create mode 100644 Tourney/src/net/anxuiz/tourney/event/mapselect/MapSelectionMapSelectEvent.java create mode 100644 Tourney/src/net/anxuiz/tourney/event/mapselect/MapSelectionMapVetoEvent.java create mode 100644 Tourney/src/net/anxuiz/tourney/event/mapselect/MapSelectionTeamEvent.java create mode 100644 Tourney/src/net/anxuiz/tourney/event/mapselect/MapSelectionTurnCycleEvent.java create mode 100644 Tourney/src/net/anxuiz/tourney/listener/GameplayListener.java create mode 100644 Tourney/src/net/anxuiz/tourney/listener/KDMListener.java create mode 100644 Tourney/src/net/anxuiz/tourney/listener/ReadyListener.java create mode 100644 Tourney/src/net/anxuiz/tourney/listener/TeamListener.java create mode 100644 Tourney/src/net/anxuiz/tourney/task/Task.java create mode 100644 Tourney/src/net/anxuiz/tourney/util/EntrantUtils.java create mode 100644 Tourney/src/net/anxuiz/tourney/vote/VetoVote.java create mode 100644 Tourney/src/net/anxuiz/tourney/vote/VoteContext.java create mode 100644 Tourney/src/resources/config.yml create mode 100644 Tourney/src/resources/plugin.yml create mode 100644 Util/bukkit/pom.xml create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/attribute/AttributeUtils.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/bossbar/BossBarFactory.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/bossbar/BossBarFactoryImpl.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/bossbar/RenderedBossBar.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/chat/Audiences.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/chat/BaseComponentRenderer.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/chat/BukkitAudiences.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/chat/BukkitSound.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/chat/CommandSenderAudience.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/chat/ComponentRenderContext.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/chat/ComponentRenderer.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/chat/ComponentRendererRegistry.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/chat/ComponentRenderers.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/chat/ConsoleAudience.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/chat/HeaderComponent.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/chat/ListComponent.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/chat/PlayerAudience.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/chat/Renderable.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/chat/RenderableComponent.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/chat/WarningComponent.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/commands/BukkitCommandManifest.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/commands/BukkitCommandRegistry.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/configuration/ConfigUtils.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/event/AdventureModeInteractEvent.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/event/BlockPunchEvent.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/event/BlockTrampleEvent.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/event/BukkitEventHandlerScanner.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/event/CoarsePlayerMoveEvent.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/event/EventHandlerInfo.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/event/EventHandlerScanner.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/event/EventKey.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/event/EventSubscriber.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/event/ExtendedCancellable.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/event/GeneralizingEvent.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/event/targeted/TargetedEventBus.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/event/targeted/TargetedEventBusImpl.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/event/targeted/TargetedEventHandler.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/event/targeted/TargetedEventHandlerScanner.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/event/targeted/TargetedEventManifest.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/event/targeted/TargetedEventRouter.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/event/targeted/TargetedEventRouterBinder.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/geometry/AffineTransform.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/geometry/Capsule.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/geometry/Direction.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/geometry/LineSegment.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/geometry/LinearFunction.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/geometry/Sphere.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/inject/BukkitFacetContext.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/inject/BukkitPlayerModule.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/inject/BukkitPluginManifest.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/inject/BukkitPluginResolver.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/inject/BukkitServerManifest.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/inject/ComponentRendererModule.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/inventory/ArmorType.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/inventory/InventorySlot.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/inventory/InventoryUtils.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/inventory/Slot.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/item/BooleanItemTag.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/item/FloatItemTag.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/item/IntegerItemTag.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/item/ItemBuilder.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/item/ItemConfigurationParser.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/item/ItemTag.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/item/ItemUtils.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/item/RenderedItemBuilder.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/item/StringItemTag.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/listeners/ButtonListener.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/listeners/ButtonManager.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/listeners/PlayerMovementListener.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/listeners/WindowListener.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/listeners/WindowManager.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/localization/BukkitTranslator.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/localization/BukkitTranslatorImpl.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/localization/Translator.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/logging/BukkitLoggerFactory.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/logging/ChatLogHandler.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/logging/ChatLogRecord.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/permissions/BukkitPermissionRegistry.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/permissions/PermissionRegistry.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/scheduler/BukkitSchedulerBackend.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/scheduler/BukkitSchedulerManifest.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/scheduler/DeferredSyncExecutor.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/scheduler/ImmediateSyncExecutor.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/suspend/SuspendListener.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/util/BlockFaces.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/util/BlockMaterialMap.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/util/BlockStateUtils.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/util/BlockUtils.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/util/BlockVectorSet.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/util/BukkitEvents.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/util/BukkitUtils.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/util/ChunkLocation.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/util/ChunkPosition.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/util/ChunkVector.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/util/ListeningMapAdapter.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/util/LivingEntityMapAdapter.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/util/LongDeque.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/util/MaterialCounter.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/util/MaterialUtils.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/util/Materials.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/util/NBTUtils.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/util/NMSHacks.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/util/NullChunkGenerator.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/util/NullCommandSender.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/util/NullPermissible.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/util/OnlinePlayerMapAdapter.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/util/PacketTracer.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/util/PotionClassification.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/util/PotionUtils.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/util/Vectors.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/util/WorldBorderUtils.java create mode 100644 Util/bukkit/src/main/java/tc/oc/commons/bukkit/util/materials/Banners.java create mode 100644 Util/bungee/pom.xml create mode 100644 Util/bungee/src/main/java/tc/oc/commons/bungee/chat/Audiences.java create mode 100644 Util/bungee/src/main/java/tc/oc/commons/bungee/chat/BungeeAudiences.java create mode 100644 Util/bungee/src/main/java/tc/oc/commons/bungee/chat/ConsoleAudience.java create mode 100644 Util/bungee/src/main/java/tc/oc/commons/bungee/chat/PlayerAudience.java create mode 100644 Util/bungee/src/main/java/tc/oc/commons/bungee/commands/BungeeCommandRegistry.java create mode 100644 Util/bungee/src/main/java/tc/oc/commons/bungee/configuration/ConfigUtils.java create mode 100644 Util/bungee/src/main/java/tc/oc/commons/bungee/inject/BungeePluginManifest.java create mode 100644 Util/bungee/src/main/java/tc/oc/commons/bungee/inject/BungeeServerManifest.java create mode 100644 Util/bungee/src/main/java/tc/oc/commons/bungee/logging/BungeeLoggerFactory.java create mode 100644 Util/bungee/src/main/java/tc/oc/commons/bungee/plugin/BungeePluginResolver.java create mode 100644 Util/bungee/src/main/java/tc/oc/commons/bungee/scheduler/BungeeSchedulerManifest.java create mode 100644 Util/core/pom.xml create mode 100644 Util/core/src/main/java/tc/oc/commons/core/FileUtils.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/IterableUtils.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/LiquidMetal.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/ListUtils.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/chat/AbstractAudience.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/chat/AbstractConsoleAudience.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/chat/AbstractMultiAudience.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/chat/Audience.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/chat/Audiences.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/chat/BlankComponent.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/chat/ChatUtils.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/chat/Component.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/chat/ComponentCollector.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/chat/Components.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/chat/ForwardingAudience.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/chat/ImmutableComponent.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/chat/MinecraftAudiences.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/chat/MultiAudience.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/chat/NullAudience.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/chat/Sound.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/collection/ConflictResolvingMap.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/collection/CountingStringMap.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/collection/FilteredBiMap.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/collection/FilteredMap.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/collection/FilteredSet.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/collection/FlatCollection.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/collection/ForwardingBiMap.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/collection/InstantMap.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/collection/IterableHelper.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/collection/MapHelper.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/collection/StringIncrementer.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/collection/TableView.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/commands/CommandBinder.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/commands/CommandExceptionHandler.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/commands/CommandFutureCallback.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/commands/CommandInvocationInfo.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/commands/CommandRegistry.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/commands/CommandRegistryImpl.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/commands/Commands.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/commands/CommandsManifest.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/commands/ComponentCommandException.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/commands/GuiceInjectorAdapter.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/commands/NestedCommands.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/commands/TranslatableCommandException.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/concurrent/AbstractContextualExecutor.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/concurrent/CatchingExecutor.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/concurrent/CatchingExecutorService.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/concurrent/CatchingRunnable.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/concurrent/ContextualExecutor.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/concurrent/ContextualExecutorImpl.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/concurrent/ExceptionHandlingExecutor.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/concurrent/ExecutorServiceDecorator.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/concurrent/ExecutorUtils.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/concurrent/FailureCallback.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/concurrent/Flexecutor.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/concurrent/FutureUtils.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/concurrent/LockMap.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/concurrent/Locker.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/concurrent/SerializingExecutor.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/concurrent/SuccessCallback.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/concurrent/TimeoutFuture.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/configuration/ConfigUtils.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/configuration/TestConfiguration.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/configuration/YamlConfiguration.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/event/EventBusModule.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/event/EventExceptionHandler.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/event/EventUtils.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/event/ReentrantEventBus.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/exception/ExceptionHandler.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/exception/FutureExceptionHandler.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/exception/InvalidMemberException.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/exception/LambdaExceptionUtils.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/exception/LoggingExceptionHandler.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/exception/NamedThreadFactory.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/formatting/PeriodFormats.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/formatting/StringUtils.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inject/AbstractBindingBuilder.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inject/BelongsTo.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inject/Binders.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inject/BindingTargetTypeResolver.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inject/Bindings.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inject/ChildConfigurator.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inject/ChildInjectorFactory.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inject/ContextualProvider.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inject/ContextualProviderModule.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inject/Dependencies.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inject/DependencyCollector.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inject/ElementExposer.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inject/ElementInspector.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inject/ElementLogger.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inject/ElementPrinter.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inject/ElementUtils.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inject/Facet.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inject/FacetBinder.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inject/FacetContext.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inject/Grapher.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inject/HybridManifest.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inject/InjectableMethod.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inject/InjectingFactory.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inject/Injection.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inject/InjectionChecks.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inject/InjectionLogger.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inject/InjectionRequest.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inject/InjectionScopable.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inject/InjectionScope.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inject/InjectionStore.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inject/InjectorScope.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inject/InjectorScopeModule.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inject/InjectorScoped.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inject/InnerFactory.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inject/InnerFactoryManifest.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inject/KeyValueStore.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inject/KeyedManifest.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inject/Keys.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inject/Manifest.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inject/Matchers.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inject/MemberInjectingFactory.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inject/OptionalProvider.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inject/PrivateBinders.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inject/ProtectedBinders.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inject/ProvidesGeneric.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inject/Proxied.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inject/ProxiedManifest.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inject/ProxyProvider.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inject/RepeatInjectionDetector.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inject/Scoper.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inject/SetBinder.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inject/SingletonManifest.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inject/SubtypeListener.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inject/TestModule.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inject/TransformableBinder.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inject/Transformer.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inject/TypeManifest.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inject/TypeMapBinder.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inject/UtilCoreManifest.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inspect/Inspectable.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inspect/InspectableProperty.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inspect/Inspection.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inspect/InspectionException.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inspect/Inspector.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inspect/MultiLineTextInspector.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inspect/ReflectiveProperty.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/inspect/TextInspector.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/localization/LocaleMatcher.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/localization/Locales.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/logging/ClassLogger.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/logging/ClassLoggerFactory.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/logging/Loggers.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/logging/Logging.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/logging/LoggingConfig.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/logging/LoggingManifest.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/logging/PluginLoggerFactory.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/logging/SimpleLoggerFactory.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/plugin/AbstractPluginResolver.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/plugin/MinecraftPluginManifest.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/plugin/PluginFacet.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/plugin/PluginFacetBinder.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/plugin/PluginFacetLoader.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/plugin/PluginFacetManifest.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/plugin/PluginResolver.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/plugin/PluginScoped.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/proxy/CachingMethodHandleInterceptor.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/proxy/MethodHandleDispatcherBase.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/proxy/MethodHandleInterceptor.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/random/AdvancingEntropy.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/random/Entropy.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/random/ImmutableWeightedRandomChooser.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/random/MutableEntropy.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/random/MutableWeightedRandomChooser.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/random/RandomUtils.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/random/SaltedEntropy.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/random/WeightedRandomChooser.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/reflect/AnnotationBase.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/reflect/Annotations.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/reflect/AutoReified.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/reflect/ClassFormException.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/reflect/Delegates.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/reflect/ElementFormException.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/reflect/FieldDelegate.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/reflect/Fields.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/reflect/GenericMethodType.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/reflect/InheritablePropertyVisitor.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/reflect/Members.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/reflect/MethodFormException.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/reflect/MethodHandleUtils.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/reflect/MethodResolver.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/reflect/MethodScanner.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/reflect/Methods.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/reflect/MinimalSupertypeSet.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/reflect/MinimalTypeSet.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/reflect/PatternInvoker.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/reflect/ReflectionFormatting.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/reflect/ReflectionVisitor.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/reflect/Reified.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/reflect/ResolvableType.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/reflect/ResolvableTypeParameter.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/reflect/TypeArgument.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/reflect/TypeCapture.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/reflect/TypeLiterals.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/reflect/TypeParameter.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/reflect/TypeParameterCache.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/reflect/TypeResolver.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/reflect/Types.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/scheduler/AbstractTask.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/scheduler/DebouncedTask.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/scheduler/DisposableTask.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/scheduler/ReusableTask.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/scheduler/Scheduler.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/scheduler/SchedulerBackend.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/scheduler/SchedulerBackendImpl.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/scheduler/Task.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/server/MinecraftServerManifest.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/stream/BiStream.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/stream/BiStreamImpl.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/stream/Collectors.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/stream/ForwardingStream.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/AmbiguousElementException.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/ArrayUtils.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/BlockingScheduledQueue.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/C3.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/CacheUtils.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/CachingMethodHandleInvoker.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/CachingProvider.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/CachingTypeMap.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/Chain.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/CheckedCloseable.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/Comparables.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/Comparators.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/Comparing.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/CompletableFutureCallback.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/Counter.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/DefaultMapAdapter.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/DefaultProvider.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/DuplicateElementException.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/EnumSets.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/ExceptionUtils.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/Forwarding.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/FunctionalMatcher.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/Functions.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/HashingInputStream.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/Holidays.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/ImmutableTypeMap.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/IndexedBiConsumer.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/IndexedConsumer.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/IndexedFunction.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/InheritingMap.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/IteratorUtils.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/Joiners.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/Lazy.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/LinkedHashMultimap.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/MapUtils.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/Maybe.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/MethodHandleInvoker.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/MultimapHelper.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/Nullables.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/NumberFactory.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/Numbers.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/OptionalUtils.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/Optionals.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/Orderable.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/Pair.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/Predicates.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/ProxyUtils.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/PunchClock.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/Ranges.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/RankedSet.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/StackTrace.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/Streams.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/SupersetView.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/SystemFutureCallback.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/Threadable.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/ThrowingBiConsumer.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/ThrowingConsumer.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/ThrowingFunction.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/ThrowingRunnable.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/ThrowingSupplier.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/TimeUtils.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/Traceable.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/TraceableWrapper.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/Traceables.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/TypeMap.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/UsageCollection.java create mode 100644 Util/core/src/main/java/tc/oc/commons/core/util/Utils.java create mode 100644 Util/core/src/main/java/tc/oc/debug/DeterministicHashcode.java create mode 100644 Util/core/src/main/java/tc/oc/debug/LeakDetector.java create mode 100644 Util/core/src/main/java/tc/oc/debug/LeakDetectorConfig.java create mode 100644 Util/core/src/main/java/tc/oc/debug/LeakDetectorImpl.java create mode 100644 Util/core/src/main/java/tc/oc/debug/LeakDetectorManifest.java create mode 100644 Util/core/src/main/java/tc/oc/evil/Decorator.java create mode 100644 Util/core/src/main/java/tc/oc/evil/DecoratorFactory.java create mode 100644 Util/core/src/main/java/tc/oc/evil/DecoratorGenerator.java create mode 100644 Util/core/src/main/java/tc/oc/evil/DecoratorProvider.java create mode 100644 Util/core/src/main/java/tc/oc/evil/JavassistDecoratorGenerator.java create mode 100644 Util/core/src/main/java/tc/oc/evil/LibCGDecoratorGenerator.java create mode 100644 Util/core/src/main/java/tc/oc/file/PathWatcher.java create mode 100644 Util/core/src/main/java/tc/oc/file/PathWatcherHandle.java create mode 100644 Util/core/src/main/java/tc/oc/file/PathWatcherService.java create mode 100644 Util/core/src/main/java/tc/oc/file/PathWatcherServiceImpl.java create mode 100644 Util/core/src/main/java/tc/oc/javassist/Javassists.java create mode 100644 Util/core/src/main/java/tc/oc/minecraft/protocol/MinecraftVersion.java create mode 100644 Util/core/src/main/java/tc/oc/minecraft/scheduler/ExecutorServiceWrapper.java create mode 100644 Util/core/src/main/java/tc/oc/minecraft/scheduler/MainThreadExecutor.java create mode 100644 Util/core/src/main/java/tc/oc/minecraft/scheduler/MinecraftExecutorManifest.java create mode 100644 Util/core/src/main/java/tc/oc/minecraft/scheduler/PluginExecutorBase.java create mode 100644 Util/core/src/main/java/tc/oc/minecraft/scheduler/Sync.java create mode 100644 Util/core/src/main/java/tc/oc/minecraft/scheduler/SyncExecutor.java create mode 100644 Util/core/src/main/java/tc/oc/minecraft/suspend/Suspendable.java create mode 100644 Util/core/src/main/java/tc/oc/minecraft/suspend/SuspendableBinder.java create mode 100644 Util/core/src/main/java/tc/oc/parse/EnumParserManifest.java create mode 100644 Util/core/src/main/java/tc/oc/parse/FormatException.java create mode 100644 Util/core/src/main/java/tc/oc/parse/MissingException.java create mode 100644 Util/core/src/main/java/tc/oc/parse/ParseException.java create mode 100644 Util/core/src/main/java/tc/oc/parse/Parser.java create mode 100644 Util/core/src/main/java/tc/oc/parse/ParserTypeLiterals.java create mode 100644 Util/core/src/main/java/tc/oc/parse/ParsersManifest.java create mode 100644 Util/core/src/main/java/tc/oc/parse/PrimitiveParserManifest.java create mode 100644 Util/core/src/main/java/tc/oc/parse/ValueException.java create mode 100644 Util/core/src/main/java/tc/oc/parse/primitive/BooleanParser.java create mode 100644 Util/core/src/main/java/tc/oc/parse/primitive/DurationParser.java create mode 100644 Util/core/src/main/java/tc/oc/parse/primitive/EnumParser.java create mode 100644 Util/core/src/main/java/tc/oc/parse/primitive/PathParser.java create mode 100644 Util/core/src/main/java/tc/oc/parse/validate/AbsolutePath.java create mode 100644 Util/core/src/main/java/tc/oc/parse/validate/NonZeroDuration.java create mode 100644 Util/core/src/main/java/tc/oc/parse/validate/NormalizedPath.java create mode 100644 Util/core/src/main/java/tc/oc/parse/validate/RelativePath.java create mode 100644 Util/core/src/main/java/tc/oc/parse/validate/Validation.java create mode 100644 Util/core/src/main/java/tc/oc/parse/xml/DocumentParser.java create mode 100644 Util/core/src/main/java/tc/oc/parse/xml/ElementParser.java create mode 100644 Util/core/src/main/java/tc/oc/parse/xml/NodeParser.java create mode 100644 Util/core/src/main/java/tc/oc/parse/xml/PrimitiveNodeParser.java create mode 100644 Util/core/src/main/java/tc/oc/parse/xml/UnrecognizedNodeException.java create mode 100644 Util/core/src/main/java/tc/oc/parse/xml/ValidatingNodeParser.java create mode 100644 Util/core/src/main/java/tc/oc/parse/xml/XML.java create mode 100644 Util/core/src/main/java/tc/oc/parse/xml/XMLManifest.java create mode 100644 Util/core/src/main/java/tc/oc/test/InjectedTestCase.java create mode 100644 Util/core/src/main/java/tc/oc/test/mockito/MockBinder.java create mode 100644 Util/core/src/main/java/tc/oc/test/mockito/MockProvider.java create mode 100644 Util/core/src/main/java/tc/oc/time/Clock.java create mode 100644 Util/core/src/main/java/tc/oc/time/FriendlyUnits.java create mode 100644 Util/core/src/main/java/tc/oc/time/Interval.java create mode 100644 Util/core/src/main/java/tc/oc/time/PeriodConverter.java create mode 100644 Util/core/src/main/java/tc/oc/time/PeriodConverters.java create mode 100644 Util/core/src/main/java/tc/oc/time/PeriodRenderer.java create mode 100644 Util/core/src/main/java/tc/oc/time/PeriodRenderers.java create mode 100644 Util/core/src/main/java/tc/oc/time/Time.java create mode 100644 Util/core/src/main/java/tc/oc/time/TimePeriod.java create mode 100644 Util/core/src/test/java/tc/oc/commons/core/inject/InjectableMethodTest.java create mode 100644 Util/core/src/test/java/tc/oc/commons/core/inject/TransformableBinderTest.java create mode 100644 Util/core/src/test/java/tc/oc/commons/random/EntropyTest.java create mode 100644 Util/core/src/test/java/tc/oc/commons/random/SaltedEntropyTest.java create mode 100644 Util/core/src/test/java/tc/oc/commons/random/WeightedRandomChooserTest.java create mode 100644 Util/core/src/test/java/tc/oc/commons/reflect/AnnotationsTest.java create mode 100644 Util/core/src/test/java/tc/oc/commons/reflect/CallableMethodTest.java create mode 100644 Util/core/src/test/java/tc/oc/commons/reflect/DelegatesTest.java create mode 100644 Util/core/src/test/java/tc/oc/commons/reflect/DynamicLambdaTest.java create mode 100644 Util/core/src/test/java/tc/oc/commons/reflect/FunctionalInterfaceUtilsTest.java create mode 100644 Util/core/src/test/java/tc/oc/commons/reflect/MethodScannerTest.java create mode 100644 Util/core/src/test/java/tc/oc/commons/reflect/TypeCoercionTest.java create mode 100644 Util/core/src/test/java/tc/oc/commons/util/ResolvableTypeTest.java create mode 100644 Util/core/src/test/java/tc/oc/commons/util/StreamUtilsTest.java create mode 100644 Util/core/src/test/java/tc/oc/evil/DecoratorFactoryTest.java create mode 100644 Util/pom.xml create mode 100644 bukkit.yml.sample create mode 100644 pom.xml diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..58ed5bb --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +# Set default behaviour, in case users don't have core.autocrlf set. +* text=auto + +# Explicitly declare text files we want to always be normalized and converted +# to native line endings on checkout. +*.java text +*.xml text +*.yml text +README.md text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b56a4a5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# Maven generated files +target +*.jar +dependency-reduced-pom.xml + +# Mac OSX generated files +.DS_Store + +# Eclipse generated files +.classpath +.project +.settings + +# IntelliJ IDEA +.idea +*.iml + +# Vim generated files +*~ +*.swp + +# XCode 3 and 4 generated files +*.mode1 +*.mode1v3 +*.mode2v3 +*.perspective +*.perspectivev3 +*.pbxuser +*.xcworkspace +xcuserdata +*class + +# CrowdIn +Commons/core/src/main/i18n/translations/* diff --git a/API/api/pom.xml b/API/api/pom.xml new file mode 100644 index 0000000..9a5a4d5 --- /dev/null +++ b/API/api/pom.xml @@ -0,0 +1,95 @@ + + 4.0.0 + + + tc.oc + api-parent + ../pom.xml + 1.11-SNAPSHOT + + + api + jar + API + ProjectAres API layer + + + + + + tc.oc + util-core + ${project.version} + + + + + + + com.google.http-client + google-http-client + 1.18.0-rc + + + com.google.http-client + google-http-client-gson + 1.18.0-rc + + + com.damnhandy + handy-uri-templates + 2.0.2 + + + com.rabbitmq + amqp-client + 3.3.5 + + + + + + junit + junit + 4.12 + test + + + + + + + . + true + ${basedir}/src/main/resources/ + + + + + org.apache.maven.plugins + maven-shade-plugin + 2.1 + + + + tc.oc:util-core + + com.google.http-client:google-http-client + com.google.http-client:google-http-client-gson + com.damnhandy:handy-uri-templates + com.rabbitmq:amqp-client + + + + + + package + + shade + + + + + + + diff --git a/API/api/src/main/java/tc/oc/api/ApiManifest.java b/API/api/src/main/java/tc/oc/api/ApiManifest.java new file mode 100644 index 0000000..5ab7c8a --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/ApiManifest.java @@ -0,0 +1,48 @@ +package tc.oc.api; + +import tc.oc.api.connectable.ConnectablesManifest; +import tc.oc.api.document.DocumentsManifest; +import tc.oc.api.engagement.EngagementModelManifest; +import tc.oc.api.games.GameModelManifest; +import tc.oc.api.maps.MapModelManifest; +import tc.oc.api.match.MatchModelManifest; +import tc.oc.api.message.MessagesManifest; +import tc.oc.api.model.ModelsManifest; +import tc.oc.api.punishments.PunishmentModelManifest; +import tc.oc.api.reports.ReportModelManifest; +import tc.oc.api.serialization.SerializationManifest; +import tc.oc.api.servers.ServerModelManifest; +import tc.oc.api.sessions.SessionModelManifest; +import tc.oc.api.tourney.TournamentModelManifest; +import tc.oc.api.trophies.TrophyModelManifest; +import tc.oc.api.users.UserModelManifest; +import tc.oc.api.whispers.WhisperModelManifest; +import tc.oc.commons.core.inject.HybridManifest; +import tc.oc.commons.core.logging.LoggingManifest; + +public final class ApiManifest extends HybridManifest { + + @Override + protected void configure() { + install(new LoggingManifest()); // Load this right away, so we don't get log spam + + publicBinder().install(new SerializationManifest()); + install(new DocumentsManifest()); + install(new MessagesManifest()); + install(new ModelsManifest()); + install(new ConnectablesManifest()); + + install(new ServerModelManifest()); + install(new UserModelManifest()); + install(new SessionModelManifest()); + install(new GameModelManifest()); + install(new ReportModelManifest()); + install(new PunishmentModelManifest()); + install(new MapModelManifest()); + install(new MatchModelManifest()); + install(new EngagementModelManifest()); + install(new WhisperModelManifest()); + install(new TrophyModelManifest()); + install(new TournamentModelManifest()); + } +} diff --git a/API/api/src/main/java/tc/oc/api/annotations/ApiRequired.java b/API/api/src/main/java/tc/oc/api/annotations/ApiRequired.java new file mode 100644 index 0000000..9b97d13 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/annotations/ApiRequired.java @@ -0,0 +1,12 @@ +package tc.oc.api.annotations; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +/** + * Generic annotation for something that requires an API connection, + * and should be ommitted or cause an error if not connected. + * + * (maybe we should make that distinction explicit?) + */ +@Retention(RetentionPolicy.RUNTIME) +public @interface ApiRequired {} diff --git a/API/api/src/main/java/tc/oc/api/annotations/Serialize.java b/API/api/src/main/java/tc/oc/api/annotations/Serialize.java new file mode 100644 index 0000000..1b65ae4 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/annotations/Serialize.java @@ -0,0 +1,19 @@ +package tc.oc.api.annotations; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +import tc.oc.api.docs.virtual.Document; + +/** + * Anything descended from {@link Document} can use this annotation to indicate that a method, + * field, or entire class/interface should be included in serialization. + * Applying it to a class is equivalent to applying it to every method declared in that class. + * + * Serialized methods must return a value and take no parameters. The returned value will be + * serialized using the method name. + */ +@Retention(RetentionPolicy.RUNTIME) +public @interface Serialize { + boolean value() default true; +} diff --git a/API/api/src/main/java/tc/oc/api/config/ApiConfiguration.java b/API/api/src/main/java/tc/oc/api/config/ApiConfiguration.java new file mode 100644 index 0000000..1773b2f --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/config/ApiConfiguration.java @@ -0,0 +1,5 @@ +package tc.oc.api.config; + +public interface ApiConfiguration { + String primaryQueueName(); +} diff --git a/API/api/src/main/java/tc/oc/api/config/ApiConstants.java b/API/api/src/main/java/tc/oc/api/config/ApiConstants.java new file mode 100644 index 0000000..aeb1538 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/config/ApiConstants.java @@ -0,0 +1,7 @@ +package tc.oc.api.config; + +public final class ApiConstants { + private ApiConstants() {} + + public static final int PROTOCOL_VERSION = 4; +} diff --git a/API/api/src/main/java/tc/oc/api/connectable/Connectable.java b/API/api/src/main/java/tc/oc/api/connectable/Connectable.java new file mode 100644 index 0000000..fda2cd2 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/connectable/Connectable.java @@ -0,0 +1,19 @@ +package tc.oc.api.connectable; + +import java.io.IOException; + +import tc.oc.minecraft.api.event.Activatable; +import tc.oc.commons.core.plugin.PluginFacet; + +/** + * Service that needs to be connected and disconnected along with the API. + * + * Use a {@link ConnectableBinder} to register these. + * + * TODO: This should probably extend {@link PluginFacet}, + * but to do that, API needs to be able to find the services bound in other plugins. + */ +public interface Connectable extends Activatable { + default void connect() throws IOException {}; + default void disconnect() throws IOException {}; +} diff --git a/API/api/src/main/java/tc/oc/api/connectable/ConnectableBinder.java b/API/api/src/main/java/tc/oc/api/connectable/ConnectableBinder.java new file mode 100644 index 0000000..2bf102e --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/connectable/ConnectableBinder.java @@ -0,0 +1,10 @@ +package tc.oc.api.connectable; + +import com.google.inject.Binder; +import tc.oc.commons.core.inject.SetBinder; + +public class ConnectableBinder extends SetBinder { + public ConnectableBinder(Binder binder) { + super(binder); + } +} diff --git a/API/api/src/main/java/tc/oc/api/connectable/ConnectablesManifest.java b/API/api/src/main/java/tc/oc/api/connectable/ConnectablesManifest.java new file mode 100644 index 0000000..5f5e7a8 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/connectable/ConnectablesManifest.java @@ -0,0 +1,14 @@ +package tc.oc.api.connectable; + +import tc.oc.commons.core.inject.HybridManifest; +import tc.oc.commons.core.plugin.PluginFacetBinder; + +public class ConnectablesManifest extends HybridManifest { + + @Override + protected void configure() { + bindAndExpose(Connector.class); + new PluginFacetBinder(binder()) + .add(Connector.class); + } +} diff --git a/API/api/src/main/java/tc/oc/api/connectable/Connector.java b/API/api/src/main/java/tc/oc/api/connectable/Connector.java new file mode 100644 index 0000000..57bfca8 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/connectable/Connector.java @@ -0,0 +1,66 @@ +package tc.oc.api.connectable; + +import java.io.IOException; +import java.util.Set; +import java.util.logging.Logger; +import javax.inject.Inject; +import javax.inject.Singleton; + +import tc.oc.commons.core.exception.ExceptionHandler; +import tc.oc.commons.core.logging.Loggers; +import tc.oc.commons.core.plugin.PluginFacet; +import tc.oc.commons.core.util.ExceptionUtils; + +import static com.google.common.base.Preconditions.checkState; +import static tc.oc.commons.core.IterableUtils.reverseForEach; +import static tc.oc.commons.core.exception.LambdaExceptionUtils.rethrowConsumer; + +@Singleton +public class Connector implements PluginFacet { + + protected final Logger logger; + private final ExceptionHandler exceptionHandler; + private final Set services; + private boolean connected; + + @Inject + Connector(Loggers loggers, ExceptionHandler exceptionHandler, Set services) { + this.exceptionHandler = exceptionHandler; + this.services = services; + this.logger = loggers.get(getClass()); + } + + private void connect(Connectable service) throws IOException { + if(service.isActive()) { + logger.fine(() -> "Connecting " + service.getClass().getName()); + service.connect(); + } + } + + private void disconnect(Connectable service) throws IOException { + if(service.isActive()) { + logger.fine(() -> "Disconnecting " + service.getClass().getName()); + service.disconnect(); + } + } + + public boolean isConnected() { + return connected; + } + + @Override + public void enable() { + checkState(!connected, "already connected"); + connected = true; + logger.fine(() -> "Connecting all services"); + ExceptionUtils.propagate(() -> services.forEach(rethrowConsumer(this::connect))); + } + + @Override + public void disable() { + checkState(connected, "not connected"); + connected = false; + logger.fine(() -> "Disconnecting all services"); + reverseForEach(services, service -> exceptionHandler.run(() -> disconnect(service))); + } +} diff --git a/API/api/src/main/java/tc/oc/api/docs/AbstractModel.java b/API/api/src/main/java/tc/oc/api/docs/AbstractModel.java new file mode 100644 index 0000000..73c9365 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/docs/AbstractModel.java @@ -0,0 +1,41 @@ +package tc.oc.api.docs; + +import javax.inject.Inject; + +import com.google.gson.Gson; +import tc.oc.api.docs.virtual.Model; +import tc.oc.api.serialization.Pretty; + +/** + * Implements some boilerplate stuff for {@link Model} + */ +public abstract class AbstractModel implements Model { + + protected @Inject @Pretty Gson prettyGson; + + @Override + public boolean equals(Object o) { + if(this == o) + return true; + if(!(o instanceof Model)) + return false; + + return _id().equals(((Model) o)._id()); + } + + @Override + public int hashCode() { + return _id().hashCode(); + } + + @Override + public String toString() { + if(prettyGson == null) return super.toString(); + + try { + return prettyGson.toJson(this); + } catch(Exception e) { + return super.toString() + " (exception trying to inspect fields: " + e + ")"; + } + } +} diff --git a/API/api/src/main/java/tc/oc/api/docs/Arena.java b/API/api/src/main/java/tc/oc/api/docs/Arena.java new file mode 100644 index 0000000..cd0f9da --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/docs/Arena.java @@ -0,0 +1,21 @@ +package tc.oc.api.docs; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import tc.oc.api.annotations.Serialize; +import tc.oc.api.docs.virtual.Model; + +@Serialize +public interface Arena extends Model { + @Nonnull String game_id(); + @Nonnull String datacenter(); + int num_playing(); + int num_queued(); + @Nullable String next_server_id(); + + @Override @Serialize(false) + default String toShortString() { + return game_id() + "[" + datacenter() + "]"; + } +} diff --git a/API/api/src/main/java/tc/oc/api/docs/BasicDeletableModel.java b/API/api/src/main/java/tc/oc/api/docs/BasicDeletableModel.java new file mode 100644 index 0000000..a0525b2 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/docs/BasicDeletableModel.java @@ -0,0 +1,26 @@ +package tc.oc.api.docs; + +import java.time.Instant; +import javax.annotation.Nullable; + +import tc.oc.api.annotations.Serialize; +import tc.oc.api.docs.virtual.DeletableModel; + +public class BasicDeletableModel extends BasicModel implements DeletableModel { + + @Serialize private Instant died_at; + + @Override public @Nullable Instant died_at() { + return died_at; + } + + @Override + public boolean dead() { + return died_at() != null; + } + + @Override + public boolean alive() { + return died_at() == null; + } +} diff --git a/API/api/src/main/java/tc/oc/api/docs/BasicModel.java b/API/api/src/main/java/tc/oc/api/docs/BasicModel.java new file mode 100644 index 0000000..1306cac --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/docs/BasicModel.java @@ -0,0 +1,28 @@ +package tc.oc.api.docs; + +import tc.oc.api.annotations.Serialize; +import tc.oc.api.docs.virtual.Model; + +/** + * Implements {@link Model#_id()} as a public field. + */ +public class BasicModel extends AbstractModel { + + @Serialize public String _id; + + public BasicModel() { + } + + public BasicModel(String _id) { + this._id = _id; + } + + @Override + public String _id() { + return _id; + } + + public String getId() { + return _id; + } +} diff --git a/API/api/src/main/java/tc/oc/api/docs/Death.java b/API/api/src/main/java/tc/oc/api/docs/Death.java new file mode 100644 index 0000000..ea47add --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/docs/Death.java @@ -0,0 +1,5 @@ +package tc.oc.api.docs; + +import tc.oc.api.docs.virtual.DeathDoc; + +public interface Death extends DeathDoc.Complete {} diff --git a/API/api/src/main/java/tc/oc/api/docs/Entrant.java b/API/api/src/main/java/tc/oc/api/docs/Entrant.java new file mode 100644 index 0000000..06946b2 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/docs/Entrant.java @@ -0,0 +1,16 @@ +package tc.oc.api.docs; + +import java.util.List; +import javax.annotation.Nonnull; + +import tc.oc.api.annotations.Serialize; +import tc.oc.api.docs.team.Team; +import tc.oc.api.docs.virtual.MatchDoc; +import tc.oc.api.docs.virtual.Model; + +@Serialize +public interface Entrant extends Model { + @Nonnull Team team(); + @Nonnull List members(); + @Nonnull List matches(); +} diff --git a/API/api/src/main/java/tc/oc/api/docs/Game.java b/API/api/src/main/java/tc/oc/api/docs/Game.java new file mode 100644 index 0000000..e929f35 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/docs/Game.java @@ -0,0 +1,19 @@ +package tc.oc.api.docs; + +import javax.annotation.Nonnull; + +import tc.oc.api.annotations.Serialize; +import tc.oc.api.docs.virtual.Model; +import tc.oc.api.docs.virtual.ServerDoc; + +@Serialize +public interface Game extends Model { + @Nonnull String name(); + int priority(); + @Nonnull ServerDoc.Visibility visibility(); + + @Override @Serialize(false) + default String toShortString() { + return name(); + } +} diff --git a/API/api/src/main/java/tc/oc/api/docs/MapRating.java b/API/api/src/main/java/tc/oc/api/docs/MapRating.java new file mode 100644 index 0000000..118edc6 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/docs/MapRating.java @@ -0,0 +1,25 @@ +package tc.oc.api.docs; + +import tc.oc.api.annotations.Serialize; +import tc.oc.api.docs.virtual.MapDoc; + +import javax.annotation.Nullable; + +@Serialize +public class MapRating { + public final String player_id; + public final @Nullable String map_id; + public final String map_name; + public final String map_version; + public final int score; + public final String comment; + + public MapRating(UserId player, MapDoc map, int score, String comment) { + this.player_id = player.player_id(); + this.map_id = map._id(); + this.map_name = map.name(); + this.map_version = map.version().toString(); + this.score = score; + this.comment = comment; + } +} diff --git a/API/api/src/main/java/tc/oc/api/docs/MatchState.java b/API/api/src/main/java/tc/oc/api/docs/MatchState.java new file mode 100644 index 0000000..96ec072 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/docs/MatchState.java @@ -0,0 +1,9 @@ +package tc.oc.api.docs; + +public enum MatchState { + IDLE, + STARTING, + HUDDLE, + RUNNING, + FINISHED +} diff --git a/API/api/src/main/java/tc/oc/api/docs/Objective.java b/API/api/src/main/java/tc/oc/api/docs/Objective.java new file mode 100644 index 0000000..3b5632e --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/docs/Objective.java @@ -0,0 +1,60 @@ +package tc.oc.api.docs; + +import java.time.Instant; +import tc.oc.api.annotations.Serialize; +import tc.oc.api.docs.virtual.Model; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +@Serialize +public interface Objective extends Model { + + @Nonnull String name(); + @Nonnull String type(); + @Nonnull String feature_id(); + @Nonnull Instant date(); + @Nonnull String match_id(); + @Nonnull String server_id(); + @Nonnull String family(); + @Nullable Double x(); + @Nullable Double y(); + @Nullable Double z(); + @Nullable String team(); + @Nullable String player(); + + @Serialize + interface Colored extends Objective { + @Nonnull String color(); + } + + @Serialize + interface DestroyableDestroy extends Objective { + default String type() { return "destroyable_destroy"; } + int blocks_broken(); + double blocks_broken_percentage(); + } + + @Serialize + interface CoreBreak extends Objective { + default String type() { return "core_break"; } + @Nonnull String material(); + } + + @Serialize + interface FlagCapture extends Colored { + default String type() { return "flag_capture"; } + @Nullable String net_id(); + } + + @Serialize + interface WoolPlace extends Colored { + default String type() { return "wool_place"; } + } + + @Override @Serialize(false) + default String toShortString() { + return name() + "[" + type() + "]"; + } + +} diff --git a/API/api/src/main/java/tc/oc/api/docs/Participation.java b/API/api/src/main/java/tc/oc/api/docs/Participation.java new file mode 100644 index 0000000..d8b420a --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/docs/Participation.java @@ -0,0 +1,33 @@ +package tc.oc.api.docs; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import java.time.Instant; +import tc.oc.api.annotations.Serialize; +import tc.oc.api.docs.virtual.Model; +import tc.oc.api.model.ModelName; + +public interface Participation { + interface Partial extends Model {} // Partials always have _id() + + @Serialize + interface Start extends Partial { + @Nonnull Instant start(); + @Nullable String team_id(); + @Nullable String league_team_id(); + @Nonnull String player_id(); + @Nonnull String family(); + @Nonnull String match_id(); + @Nonnull String server_id(); + @Nonnull String session_id(); + } + + @Serialize + interface Finish extends Partial { + @Nullable Instant end(); + } + + @ModelName("Participation") + interface Complete extends Start, Finish {} +} diff --git a/API/api/src/main/java/tc/oc/api/docs/PlayerId.java b/API/api/src/main/java/tc/oc/api/docs/PlayerId.java new file mode 100644 index 0000000..ecc6773 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/docs/PlayerId.java @@ -0,0 +1,25 @@ +package tc.oc.api.docs; + +import javax.annotation.Nonnull; + +import tc.oc.api.annotations.Serialize; +import tc.oc.api.docs.virtual.CompetitorDoc; +import tc.oc.api.docs.virtual.Model; + +/** + * Subclass of {@link UserId} that adds a {@link #username()} field, which contains + * the most recently seen username for the player. It also extends {@link Model}, + * so the {@link #_id()} field is available. + * + * This class is used to pass an ID and username around together, so that it can both be + * displayed and used in the DB (like we used to be able to do with usernames alone). + */ +@Serialize +public interface PlayerId extends UserId, Model, CompetitorDoc { + @Nonnull String username(); + + @Override @Serialize(false) + default String toShortString() { + return username(); + } +} diff --git a/API/api/src/main/java/tc/oc/api/docs/Punishment.java b/API/api/src/main/java/tc/oc/api/docs/Punishment.java new file mode 100644 index 0000000..e0bea4c --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/docs/Punishment.java @@ -0,0 +1,5 @@ +package tc.oc.api.docs; + +import tc.oc.api.docs.virtual.PunishmentDoc; + +public interface Punishment extends PunishmentDoc.Complete {} diff --git a/API/api/src/main/java/tc/oc/api/docs/Report.java b/API/api/src/main/java/tc/oc/api/docs/Report.java new file mode 100644 index 0000000..caac5ab --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/docs/Report.java @@ -0,0 +1,5 @@ +package tc.oc.api.docs; + +import tc.oc.api.docs.virtual.ReportDoc; + +public interface Report extends ReportDoc.Complete {} diff --git a/API/api/src/main/java/tc/oc/api/docs/SemanticVersion.java b/API/api/src/main/java/tc/oc/api/docs/SemanticVersion.java new file mode 100644 index 0000000..9e3ffa7 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/docs/SemanticVersion.java @@ -0,0 +1,82 @@ +package tc.oc.api.docs; + +public class SemanticVersion { + protected final int major; + protected final int minor; + protected final int patch; + + public SemanticVersion(int major, int minor, int patch) { + this.major = major; + this.minor = minor; + this.patch = patch; + } + + public int major() { + return this.major; + } + + public int minor() { + return this.minor; + } + + public int patch() { + return this.patch; + } + + @Override + public String toString() { + if(patch == 0) { + return major + "." + minor; + } else { + return major + "." + minor + "." + patch; + } + } + + /** + * Return true if the major versions match and the minor version + * and patch levels are less or equal to the given version + */ + public boolean isNoNewerThan(SemanticVersion spec) { + return this.major == spec.major && + (this.minor < spec.minor || + (this.minor == spec.minor && + this.patch <= spec.patch)); + } + + /** + * Return true if the major versions match and the minor version + * and patch levels are greater than the given version + */ + public boolean isNewerThan(SemanticVersion spec) { + return this.major == spec.major && + (this.minor > spec.minor || + (this.minor == spec.minor && + this.patch > spec.patch)); + } + + /** + * Return true if the major versions match and the minor version + * and patch levels are greater or equal to the given version + */ + public boolean isNoOlderThan(SemanticVersion spec) { + return this.major == spec.major && + (this.minor > spec.minor || + (this.minor == spec.minor && + this.patch >= spec.patch)); + } + + /** + * Return true if the major versions match and the minor version + * and patch levels are less than the given version + */ + public boolean isOlderThan(SemanticVersion spec) { + return this.major == spec.major && + (this.minor < spec.minor || + (this.minor == spec.minor && + this.patch < spec.patch)); + } + + public int[] toArray() { + return new int[] {major, minor, patch}; + } +} diff --git a/API/api/src/main/java/tc/oc/api/docs/Server.java b/API/api/src/main/java/tc/oc/api/docs/Server.java new file mode 100644 index 0000000..fbca17a --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/docs/Server.java @@ -0,0 +1,5 @@ +package tc.oc.api.docs; + +import tc.oc.api.docs.virtual.ServerDoc; + +public interface Server extends ServerDoc.Complete {} diff --git a/API/api/src/main/java/tc/oc/api/docs/Session.java b/API/api/src/main/java/tc/oc/api/docs/Session.java new file mode 100644 index 0000000..2cf110a --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/docs/Session.java @@ -0,0 +1,5 @@ +package tc.oc.api.docs; + +import tc.oc.api.docs.virtual.SessionDoc; + +public interface Session extends SessionDoc.Complete {} diff --git a/API/api/src/main/java/tc/oc/api/docs/SimplePlayerId.java b/API/api/src/main/java/tc/oc/api/docs/SimplePlayerId.java new file mode 100644 index 0000000..19838d9 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/docs/SimplePlayerId.java @@ -0,0 +1,56 @@ +package tc.oc.api.docs; + +import tc.oc.api.annotations.Serialize; + +import static com.google.common.base.Preconditions.checkNotNull; + +public class SimplePlayerId extends SimpleUserId implements PlayerId { + + @Serialize private String _id; + @Serialize private String username; + + public SimplePlayerId(String _id, String player_id, String username) { + super(player_id); + this._id = checkNotNull(_id); + this.username = checkNotNull(username); + } + + public SimplePlayerId(PlayerId playerId) { + this(playerId._id(), playerId.player_id(), playerId.username()); + } + + /** For deserialization only */ + protected SimplePlayerId() { + super(); + this._id = this.username = null; + } + + /** + * Return a {@link SimplePlayerId} equal to the given {@link PlayerId} + */ + public static SimplePlayerId copyOf(PlayerId playerId) { + return playerId.getClass().equals(SimplePlayerId.class) ? (SimplePlayerId) playerId + : new SimplePlayerId(playerId); + } + + @Serialize + @Override + public String _id() { + return _id; + } + + @Serialize + @Override + public String username() { + return username; + } + + @Override + public String toString() { + return getClass().getSimpleName() + + "{_id=" + _id() + + " player_id=" + player_id() + + " username=" + username() + + "}"; + } +} diff --git a/API/api/src/main/java/tc/oc/api/docs/SimpleUserId.java b/API/api/src/main/java/tc/oc/api/docs/SimpleUserId.java new file mode 100644 index 0000000..37cc925 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/docs/SimpleUserId.java @@ -0,0 +1,46 @@ +package tc.oc.api.docs; + +import tc.oc.api.annotations.Serialize; + +import static com.google.common.base.Preconditions.checkNotNull; + +public class SimpleUserId implements UserId { + + @Serialize private String player_id; + + public SimpleUserId(String player_id) { + this.player_id = checkNotNull(player_id); + } + + /** For deserialization only */ + protected SimpleUserId() { + this.player_id = null; + } + + public static SimpleUserId copyOf(UserId userId) { + return userId.getClass().equals(SimpleUserId.class) ? (SimpleUserId) userId + : new SimpleUserId(userId.player_id()); + } + + @Serialize + @Override + public String player_id() { + return this.player_id; + } + + @Override + final public boolean equals(Object obj) { + return obj instanceof UserId && ((UserId) obj).player_id().equals(this.player_id()); + } + + @Override + final public int hashCode() { + return this.player_id().hashCode(); + } + + @Override + public String toString() { + return getClass().getSimpleName() + + "{player_id=" + player_id() + "}"; + } +} diff --git a/API/api/src/main/java/tc/oc/api/docs/Ticket.java b/API/api/src/main/java/tc/oc/api/docs/Ticket.java new file mode 100644 index 0000000..94cbf89 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/docs/Ticket.java @@ -0,0 +1,21 @@ +package tc.oc.api.docs; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import java.time.Instant; +import tc.oc.api.annotations.Serialize; +import tc.oc.api.docs.virtual.Model; + +@Serialize +public interface Ticket extends Model { + @Nonnull PlayerId user(); + @Nonnull String arena_id(); + @Nullable String server_id(); + @Nullable Instant dispatched_at(); + + @Override @Serialize(false) + default String toShortString() { + return user().toShortString(); + } +} diff --git a/API/api/src/main/java/tc/oc/api/docs/Tournament.java b/API/api/src/main/java/tc/oc/api/docs/Tournament.java new file mode 100644 index 0000000..8db7b63 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/docs/Tournament.java @@ -0,0 +1,43 @@ +package tc.oc.api.docs; + +import java.time.Instant; +import java.util.List; +import java.util.Set; + +import com.google.common.collect.Lists; +import tc.oc.api.annotations.Serialize; +import tc.oc.api.docs.virtual.Document; +import tc.oc.api.docs.virtual.MapDoc; +import tc.oc.api.docs.virtual.Model; + +@Serialize +public interface Tournament extends Model { + + String name(); + Instant start(); + Instant end(); + + int min_players_per_match(); + int max_players_per_match(); + + List accepted_teams(); + + default List acceptedTeamNames() { + return Lists.transform(accepted_teams(), team.Id::name); + } + + /** + * game type -> [map ids] + * + * Key is some kind of string identifying game type e.g. "core", "wool", "tdm" + * + * Value is a set of {@link MapDoc#_id()} + */ + List map_classifications(); + + @Serialize + interface MapClassification extends Document { + String name(); + Set map_ids(); + } +} diff --git a/API/api/src/main/java/tc/oc/api/docs/Trophy.java b/API/api/src/main/java/tc/oc/api/docs/Trophy.java new file mode 100644 index 0000000..5496c71 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/docs/Trophy.java @@ -0,0 +1,19 @@ +package tc.oc.api.docs; + +import tc.oc.api.annotations.Serialize; +import tc.oc.api.docs.virtual.Model; + +import javax.annotation.Nonnull; + +@Serialize +public interface Trophy extends Model { + + @Nonnull String name(); + @Nonnull String description(); + + @Override @Serialize(false) + default String toShortString() { + return _id(); + } + +} diff --git a/API/api/src/main/java/tc/oc/api/docs/User.java b/API/api/src/main/java/tc/oc/api/docs/User.java new file mode 100644 index 0000000..6e734ad --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/docs/User.java @@ -0,0 +1,5 @@ +package tc.oc.api.docs; + +import tc.oc.api.docs.virtual.UserDoc; + +public interface User extends UserDoc.Login {} diff --git a/API/api/src/main/java/tc/oc/api/docs/UserId.java b/API/api/src/main/java/tc/oc/api/docs/UserId.java new file mode 100644 index 0000000..be5411d --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/docs/UserId.java @@ -0,0 +1,21 @@ +package tc.oc.api.docs; + +import javax.annotation.Nonnull; + +import tc.oc.api.annotations.Serialize; +import tc.oc.api.docs.virtual.UserDoc; + +/** + * Wrapper for user.player_id values. It identifies a player stored in the DB, + * but contains no username, which has a few ramifications: + * + * - You cannot display this to the user + * - You cannot directly associate this with an online player + * + * Doing either of the above requires a lookup in the DB or PlayerIdMap. + * See the subclasses of this class for more explanation. + */ +@Serialize +public interface UserId extends UserDoc.Partial { + @Nonnull String player_id(); +} diff --git a/API/api/src/main/java/tc/oc/api/docs/Whisper.java b/API/api/src/main/java/tc/oc/api/docs/Whisper.java new file mode 100644 index 0000000..a5dc02d --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/docs/Whisper.java @@ -0,0 +1,7 @@ +package tc.oc.api.docs; + +import tc.oc.api.annotations.Serialize; +import tc.oc.api.docs.virtual.WhisperDoc; + +@Serialize +public interface Whisper extends WhisperDoc.Complete {} diff --git a/API/api/src/main/java/tc/oc/api/docs/team.java b/API/api/src/main/java/tc/oc/api/docs/team.java new file mode 100644 index 0000000..1256f3a --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/docs/team.java @@ -0,0 +1,28 @@ +package tc.oc.api.docs; + +import java.util.List; +import javax.annotation.Nonnull; + +import tc.oc.api.annotations.Serialize; +import tc.oc.api.docs.virtual.Model; +import tc.oc.api.docs.virtual.PartialModel; + +public interface team { + + interface Partial extends PartialModel {} + + @Serialize + interface Id extends Partial, Model { + @Nonnull String name(); + @Nonnull String name_normalized(); + + } + + @Serialize + interface Members extends Partial { + @Nonnull PlayerId leader(); + @Nonnull List members(); + } + + interface Team extends Id, Members {} +} diff --git a/API/api/src/main/java/tc/oc/api/docs/virtual/BasicDocument.java b/API/api/src/main/java/tc/oc/api/docs/virtual/BasicDocument.java new file mode 100644 index 0000000..2e6d147 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/docs/virtual/BasicDocument.java @@ -0,0 +1,4 @@ +package tc.oc.api.docs.virtual; + +public class BasicDocument implements Document { +} diff --git a/API/api/src/main/java/tc/oc/api/docs/virtual/CompetitorDoc.java b/API/api/src/main/java/tc/oc/api/docs/virtual/CompetitorDoc.java new file mode 100644 index 0000000..5c604ec --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/docs/virtual/CompetitorDoc.java @@ -0,0 +1,3 @@ +package tc.oc.api.docs.virtual; + +public interface CompetitorDoc extends Model {} diff --git a/API/api/src/main/java/tc/oc/api/docs/virtual/DeathDoc.java b/API/api/src/main/java/tc/oc/api/docs/virtual/DeathDoc.java new file mode 100644 index 0000000..fdbedea --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/docs/virtual/DeathDoc.java @@ -0,0 +1,50 @@ +package tc.oc.api.docs.virtual; + +import java.time.Instant; +import tc.oc.api.annotations.Serialize; +import tc.oc.api.docs.PlayerId; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +public interface DeathDoc { + + interface Partial extends PartialModel {} + + @Serialize + interface Complete extends Base { + @Nonnull PlayerId victim(); + @Nullable PlayerId killer(); + } + + @Serialize + interface Creation extends Base { + @Nonnull String victim(); + @Nullable String killer(); + int raindrops(); + } + + @Serialize + interface Base extends Model, Partial { + double x(); + double y(); + double z(); + @Nonnull String server_id(); + @Nonnull String match_id(); + @Nonnull String family(); + @Nonnull Instant date(); + @Nullable String entity_killer(); + @Nullable String block_killer(); + @Nullable Boolean player_killer(); + @Nullable Boolean teamkill(); + @Nullable String victim_class(); + @Nullable String killer_class(); + @Nullable Double distance(); + @Nullable Boolean enchanted(); + @Nullable String weapon(); + @Nullable String from(); + @Nullable String action(); + @Nullable String cause(); + } + +} diff --git a/API/api/src/main/java/tc/oc/api/docs/virtual/DeletableModel.java b/API/api/src/main/java/tc/oc/api/docs/virtual/DeletableModel.java new file mode 100644 index 0000000..e743044 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/docs/virtual/DeletableModel.java @@ -0,0 +1,12 @@ +package tc.oc.api.docs.virtual; + +import java.time.Instant; +import javax.annotation.Nullable; + +import tc.oc.api.annotations.Serialize; + +public interface DeletableModel extends Model { + @Serialize @Nullable Instant died_at(); + boolean dead(); + boolean alive(); +} diff --git a/API/api/src/main/java/tc/oc/api/docs/virtual/DeployInfo.java b/API/api/src/main/java/tc/oc/api/docs/virtual/DeployInfo.java new file mode 100644 index 0000000..de74fab --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/docs/virtual/DeployInfo.java @@ -0,0 +1,23 @@ +package tc.oc.api.docs.virtual; + +import java.util.Map; + +import tc.oc.api.annotations.Serialize; + +@Serialize +public interface DeployInfo extends Document { + @Serialize + interface Version extends Document { + String branch(); + String commit(); + } + + @Serialize + interface Nextgen extends Document { + String path(); + Version version(); + } + + Nextgen nextgen(); + Map packages(); +} diff --git a/API/api/src/main/java/tc/oc/api/docs/virtual/Document.java b/API/api/src/main/java/tc/oc/api/docs/virtual/Document.java new file mode 100644 index 0000000..6a40b65 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/docs/virtual/Document.java @@ -0,0 +1,28 @@ +package tc.oc.api.docs.virtual; + +import tc.oc.api.annotations.Serialize; +import tc.oc.api.document.DocumentMeta; +import tc.oc.api.document.DocumentRegistry; +import tc.oc.api.message.Message; +import tc.oc.api.document.DocumentSerializer; + +/** + * Base interface for serializable API documents, including documents stored + * in the database ({@link Model}s and {@link PartialModel}s) and directives + * exchanged through the API ({@link Message}s). + * + * {@link Document}s are serialized differently than normal objects. No fields + * are included in serialization by default. Rather, the {@link Serialize} + * annotation is used to mark serialized fields. Methods can also be annotated + * with {@link Serialize} to mark them as getters or setters. Getter methods + * must return a value and take no parameters, while setter methods must take + * exactly one parameter. If a class or interface is annotated with + * {@link Serialize}, all fields and methods declared in that class will be + * included in serialization. + * + * {@link DocumentSerializer} is responsible for serializing and deserializing + * {@link Document}s. It uses {@link DocumentRegistry} to get a {@link DocumentMeta} + * for a class, which knows about all the getters and setters. Registration + * happens on demand and is entirely automatic. + */ +public interface Document {} diff --git a/API/api/src/main/java/tc/oc/api/docs/virtual/EngagementDoc.java b/API/api/src/main/java/tc/oc/api/docs/virtual/EngagementDoc.java new file mode 100644 index 0000000..6872d67 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/docs/virtual/EngagementDoc.java @@ -0,0 +1,48 @@ +package tc.oc.api.docs.virtual; + +import javax.annotation.Nullable; + +import java.time.Duration; +import java.time.Instant; +import tc.oc.api.annotations.Serialize; +import tc.oc.api.docs.SemanticVersion; +import tc.oc.api.model.ModelName; + +@Serialize +@ModelName("Engagement") +public interface EngagementDoc extends Model { + String family_id(); + String server_id(); + String user_id(); + MapDoc.Genre genre(); + + String match_id(); + Instant match_started_at(); + Instant match_joined_at(); + @Nullable Instant match_finished_at(); + @Nullable Duration match_length(); + @Nullable Duration match_participation(); + + boolean committed(); + + String map_id(); + SemanticVersion map_version(); + + int player_count(); + int competitor_count(); + + @Nullable String team_pgm_id(); + @Nullable Integer team_size(); + @Nullable Duration team_participation(); + + @Nullable Integer rank(); + @Nullable Integer tied_count(); + + enum ForfeitReason { + ABSENCE, + PARTICIPATION_PERCENT, + CUMULATIVE_ABSENCE, + CONTINUOUS_ABSENCE + } + @Nullable ForfeitReason forfeit_reason(); +} diff --git a/API/api/src/main/java/tc/oc/api/docs/virtual/EngagementDocBase.java b/API/api/src/main/java/tc/oc/api/docs/virtual/EngagementDocBase.java new file mode 100644 index 0000000..0f2736e --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/docs/virtual/EngagementDocBase.java @@ -0,0 +1,79 @@ +package tc.oc.api.docs.virtual; + +import javax.annotation.Nullable; + +import java.time.Duration; +import java.time.Instant; +import tc.oc.api.docs.BasicModel; +import tc.oc.api.docs.SemanticVersion; + +public abstract class EngagementDocBase extends BasicModel implements EngagementDoc { + + protected final MatchDoc matchDocument; + + protected EngagementDocBase(String _id, MatchDoc matchDocument) { + super(_id); + this.matchDocument = matchDocument; + } + + @Override + public String server_id() { + return matchDocument.server_id(); + } + + @Override + public String family_id() { + return matchDocument.family_id(); + } + + @Override + public MapDoc.Genre genre() { + return matchDocument.map().genre(); + } + + @Override + public String match_id() { + return matchDocument._id(); + } + + @Override + public Instant match_started_at() { + return matchDocument.start(); + } + + @Override + public @Nullable Instant match_finished_at() { + return matchDocument.end(); + } + + @Override + public @Nullable Duration match_length() { + Instant start = matchDocument.start(); + Instant end = matchDocument.end(); + if(start != null && end != null) { + return Duration.between(start, end); + } else { + return null; + } + } + + @Override + public String map_id() { + return matchDocument.map()._id(); + } + + @Override + public SemanticVersion map_version() { + return matchDocument.map().version(); + } + + @Override + public int player_count() { + return matchDocument.player_count(); + } + + @Override + public int competitor_count() { + return matchDocument.competitors().size(); + } +} diff --git a/API/api/src/main/java/tc/oc/api/docs/virtual/MapDoc.java b/API/api/src/main/java/tc/oc/api/docs/virtual/MapDoc.java new file mode 100644 index 0000000..e52634c --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/docs/virtual/MapDoc.java @@ -0,0 +1,62 @@ +package tc.oc.api.docs.virtual; + +import java.nio.file.Path; +import java.util.Collection; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import net.md_5.bungee.api.ChatColor; +import tc.oc.api.annotations.Serialize; +import tc.oc.api.docs.SemanticVersion; +import tc.oc.api.model.ModelName; + +@Serialize +@Nonnull +@ModelName("Map") +public interface MapDoc extends Model { + String slug(); + String name(); + @Nullable String url(); + @Nullable Path path(); + Collection images(); + SemanticVersion version(); + int min_players(); + int max_players(); + String objective(); + + enum Phase { + DEVELOPMENT, PRODUCTION; + public static final Phase DEFAULT = PRODUCTION; + } + Phase phase(); + + enum Edition { + STANDARD, RANKED, TOURNAMENT; + public static final Edition DEFAULT = STANDARD; + } + Edition edition(); + + enum Genre { OBJECTIVES, DEATHMATCH, OTHER } + Genre genre(); + + enum Gamemode { tdm, ctw, ctf, dtc, dtm, ad, koth, blitz, rage, scorebox, arcade, gs, ffa, mixed, skywars, survival } + Set gamemode(); + + List teams(); + + Collection author_uuids(); + Collection contributor_uuids(); + + @Serialize + interface Team extends Model { + @Nonnull String name(); + + // Fields below shouldn't be nullable, but data is missing in some old match documents + @Nullable Integer min_players(); + @Nullable Integer max_players(); + @Nullable ChatColor color(); + } +} diff --git a/API/api/src/main/java/tc/oc/api/docs/virtual/MatchDoc.java b/API/api/src/main/java/tc/oc/api/docs/virtual/MatchDoc.java new file mode 100644 index 0000000..01aea88 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/docs/virtual/MatchDoc.java @@ -0,0 +1,86 @@ +package tc.oc.api.docs.virtual; + +import java.util.Collection; +import java.util.Set; +import javax.annotation.Nullable; + +import java.time.Instant; +import tc.oc.api.annotations.Serialize; +import tc.oc.api.model.ModelName; + +@Serialize +@ModelName(value = "Match", plural = "matches") +public interface MatchDoc extends Model { + String server_id(); + String family_id(); + + // Can be null if doc was generated by the API and the map is not in the DB + MapDoc map(); + Collection competitors(); + Collection objectives(); + + Instant load(); + @Nullable Instant start(); + @Nullable Instant end(); + @Nullable Instant unload(); + + boolean join_mid_match(); + int player_count(); + + Collection winning_team_ids(); + Collection winning_user_ids(); + + enum Mutation { + BLITZ, UHC, EXPLOSIVES, NO_FALL, MOBS, STRENGTH, DOUBLE_JUMP, INVISIBILITY, LIGHTNING, RAGE, ELYTRA; + } + + Set mutations(); + + @Serialize + interface Team extends MapDoc.Team, CompetitorDoc { + @Nullable Integer size(); // Shouldn't be nullable, but data is missing in some old match documents + String league_team_id(); + } + + @Serialize + interface Goal extends Model { + String type(); + String name(); + } + + @Serialize + interface OwnedGoal extends Goal { + @Nullable String owner_id(); + @Nullable String owner_name(); + } + + @Serialize + interface IncrementalGoal extends Goal { + double completion(); + } + + @Serialize + interface TouchableGoal extends OwnedGoal { + Collection proximities(); + + @Serialize + interface Proximity extends Model { + boolean touched(); + @Nullable Metric metric(); + double distance(); + + enum Metric { + CLOSEST_PLAYER, CLOSEST_PLAYER_HORIZONTAL, + CLOSEST_BLOCK, CLOSEST_BLOCK_HORIZONTAL, + CLOSEST_KILL, CLOSEST_KILL_HORIZONTAL; + } + } + } + + @Serialize + interface Destroyable extends TouchableGoal, IncrementalGoal { + int total_blocks(); + int breaks_required(); + int breaks(); + } +} diff --git a/API/api/src/main/java/tc/oc/api/docs/virtual/Model.java b/API/api/src/main/java/tc/oc/api/docs/virtual/Model.java new file mode 100644 index 0000000..1238e4c --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/docs/virtual/Model.java @@ -0,0 +1,9 @@ +package tc.oc.api.docs.virtual; + +import tc.oc.api.annotations.Serialize; + +@Serialize +public interface Model extends PartialModel { + String _id(); + @Serialize(false) default String toShortString() { return toString(); } +} diff --git a/API/api/src/main/java/tc/oc/api/docs/virtual/PartialModel.java b/API/api/src/main/java/tc/oc/api/docs/virtual/PartialModel.java new file mode 100644 index 0000000..5b6895a --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/docs/virtual/PartialModel.java @@ -0,0 +1,37 @@ +package tc.oc.api.docs.virtual; + +/** + * A subset of the fields in some {@link Model} serving some particular use case. + * + * ### How the model hiearchy works ### + * + * PartialModel ---------> Thing.Partial + * | | + * | +------+------+ + * | | | + * | v v + * | Thing.FooInfo Thing.BarInfo + * | | | + * | +------+------+ + * | | + * | v + * Model ------------> Thing.Complete + * + * The hiearchy for a model called Thing would start with an empty interface + * called Thing.Partial that extends {@link PartialModel}. For every sub-group + * of Thing fields that are sent/received together, there would be an interface + * extending Thing.Partial that declares getter methods for those fields only. + * + * At the bottom of the hiearchy would be an interface called Thing.Complete + * that extends {@link Model} and ALL of the Thing parts. Any fields that are not + * declared in any of the parts should be declared in the complete model. + * + * Fields can be shared among different parts of the same model, as long as they + * have identical signatures in all of the parts where they appear. + * + * This system ties together all the parts of a model in a type-safe way, ensuring + * that the fields stay in sync. Model classes should be created for specific cases, + * and only implement the interfaces containing the fields that they use, avoiding + * empty fields and nulls. + */ +public interface PartialModel extends Document {} diff --git a/API/api/src/main/java/tc/oc/api/docs/virtual/PunishmentDoc.java b/API/api/src/main/java/tc/oc/api/docs/virtual/PunishmentDoc.java new file mode 100644 index 0000000..d1c3cd8 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/docs/virtual/PunishmentDoc.java @@ -0,0 +1,68 @@ +package tc.oc.api.docs.virtual; + +import tc.oc.api.annotations.Serialize; +import tc.oc.api.docs.PlayerId; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.time.Instant; + +public interface PunishmentDoc { + interface Partial extends PartialModel {} + + @Serialize + interface Base extends Model, Partial { + @Nullable String match_id(); + @Nullable String server_id(); + @Nullable Instant expire(); + @Nullable String family(); + @Nonnull String reason(); + @Nonnull Instant date(); + boolean debatable(); + boolean silent(); + boolean automatic(); + boolean active(); + } + + @Serialize + interface Creation extends Base { + @Nullable String punisher_id(); + @Nullable String punished_id(); + @Nullable Type type(); + boolean off_record(); + } + + @Serialize + interface Complete extends Base, Enforce, Evidence { + @Nullable PlayerId punisher(); + @Nullable PlayerId punished(); + @Nonnull Type type(); + boolean stale(); + } + + @Serialize + interface Enforce extends Partial { + boolean enforced(); + } + + @Serialize + interface Evidence extends Partial { + @Nullable String evidence(); + } + + enum Type { + + WARN, + KICK, + BAN, + FORUM_WARN, + FORUM_BAN, + TOURNEY_BAN, + UNKNOWN; + + public String permission() { + return name().toLowerCase(); + } + } + +} diff --git a/API/api/src/main/java/tc/oc/api/docs/virtual/ReportDoc.java b/API/api/src/main/java/tc/oc/api/docs/virtual/ReportDoc.java new file mode 100644 index 0000000..7b24621 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/docs/virtual/ReportDoc.java @@ -0,0 +1,37 @@ +package tc.oc.api.docs.virtual; + +import java.util.List; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import java.time.Instant; +import tc.oc.api.annotations.Serialize; +import tc.oc.api.docs.PlayerId; + +public interface ReportDoc { + interface Partial extends PartialModel {} + + @Serialize + interface Base extends Model, Partial { + @Nonnull String scope(); + boolean automatic(); + @Nullable String family(); + @Nullable String server_id(); + @Nullable String match_id(); + @Nonnull String reason(); + @Nullable List staff_online(); + } + + @Serialize + interface Creation extends Base { + @Nullable String reporter_id(); + @Nonnull String reported_id(); + } + + @Serialize + interface Complete extends Base { + @Nonnull Instant created_at(); + @Nullable PlayerId reporter(); + @Nullable PlayerId reported(); + } +} diff --git a/API/api/src/main/java/tc/oc/api/docs/virtual/ServerDoc.java b/API/api/src/main/java/tc/oc/api/docs/virtual/ServerDoc.java new file mode 100644 index 0000000..c646e22 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/docs/virtual/ServerDoc.java @@ -0,0 +1,194 @@ +package tc.oc.api.docs.virtual; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import javax.annotation.Nullable; + +import java.time.Instant; +import tc.oc.api.annotations.Serialize; +import tc.oc.api.docs.team.Team; + +public interface ServerDoc { + + interface Complete extends DeletableModel, Listing, Startup, Configuration, Bungee, Restart { + @Override + default String toShortString() { + return bungee_name(); + } + } + + interface Partial extends PartialModel {} + + /** + * Info displayed in server listings (signs, picker, etc) + */ + interface Listing extends Identity, Visible, Status, RestartQueuedAt, Mutation {} + + enum Role { + BUNGEE, LOBBY, PGM, MAPDEV; + } + + enum Network { + PUBLIC, PRIVATE, TOURNAMENT; + } + + /** + * Things that are available in the config file i.e. that can't change dynamically + */ + @Serialize + interface Deployment extends Partial { + String datacenter(); + String box(); + Role role(); + } + + @Serialize + interface BungeeName extends Partial { + @Nullable String bungee_name(); + } + + @Serialize + interface CurrentPort extends Partial { + Integer current_port(); + } + + @Serialize + interface Online extends Partial { + boolean online(); + } + + @Serialize + interface Dns extends Partial { + boolean dns_enabled(); + @Nullable Instant dns_toggled_at(); + } + + @Serialize + interface Identity extends DeletableModel, BungeeName, Deployment { + int priority(); + @Nullable String family(); + String ip(); + String name(); + @Nullable String description(); + Network network(); + Set realms(); + @Nullable String game_id(); + @Nullable String arena_id(); + + default String slug() { + return role() == Role.BUNGEE ? name() : bungee_name(); + } + } + + enum Visibility { + UNKNOWN, PUBLIC, PRIVATE, UNLISTED; + } + + @Serialize + interface Visible extends Partial { + Visibility visibility(); + } + + /** + * Startup info sent to the API + */ + @Serialize + interface Startup extends Online, CurrentPort { + @Nullable DeployInfo deploy_info(); + Map plugin_versions(); + Set protocol_versions(); + } + + /** + * Startup info received from the API + */ + @Serialize + interface Configuration extends Partial { + String settings_profile(); + Map operators(); + @Nullable Team team(); + Set participant_uuids(); + Map participant_permissions(); + Map observer_permissions(); + Map mapmaker_permissions(); + Visibility startup_visibility(); + boolean whitelist_enabled(); + boolean waiting_room(); + + @Nullable String resource_pack_url(); + @Nullable String resource_pack_sha1(); + boolean resource_pack_fast_update(); + } + + @Serialize + interface PlayerCounts extends Partial { + default int min_players() { return 0; } + int max_players(); + int num_observing(); + } + + @Serialize + interface MatchStatus extends Partial { + @Nullable MatchDoc current_match(); + int num_participating(); + @Nullable MapDoc next_map(); + } + + @Serialize + interface Mutation extends Partial { + Set queued_mutations(); + } + + /** + * Status sent to the API from Lobby + */ + @Serialize + interface StatusUpdate extends PlayerCounts {} + + /** + * Status sent to the API from PGM + */ + @Serialize + interface MatchStatusUpdate extends StatusUpdate, MatchStatus {} + + /** + * Status received from the API + */ + @Serialize + interface Status extends MatchStatusUpdate { + boolean running(); + boolean online(); + int num_online(); + } + + @Serialize + interface RestartQueuedAt extends Partial { + @Nullable Instant restart_queued_at(); + } + + @Serialize + interface Restart extends RestartQueuedAt { + interface Priority { + int LOW = -10; + int NORMAL = 0; + int HIGH = 10; + } + + @Nullable String restart_reason(); + default int restart_priority() { return 0; } + } + + @Serialize + interface Bungee extends Dns { + Map fake_usernames(); + List banners(); + } + + @Serialize + interface Banner extends Document { + String rendered(); + float weight(); + } +} diff --git a/API/api/src/main/java/tc/oc/api/docs/virtual/SessionDoc.java b/API/api/src/main/java/tc/oc/api/docs/virtual/SessionDoc.java new file mode 100644 index 0000000..990bc41 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/docs/virtual/SessionDoc.java @@ -0,0 +1,24 @@ +package tc.oc.api.docs.virtual; + +import java.time.Instant; +import javax.annotation.Nullable; + +import tc.oc.api.annotations.Serialize; +import tc.oc.api.docs.PlayerId; + +public interface SessionDoc { + + interface Partial extends PartialModel {} + + @Serialize + interface Complete extends Model, Partial { + String family_id(); + String server_id(); + PlayerId user(); + @Nullable String nickname(); + @Nullable String nickname_lower(); + String ip(); + Instant start(); + @Nullable Instant end(); + } +} diff --git a/API/api/src/main/java/tc/oc/api/docs/virtual/UserDoc.java b/API/api/src/main/java/tc/oc/api/docs/virtual/UserDoc.java new file mode 100644 index 0000000..f9d5ffb --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/docs/virtual/UserDoc.java @@ -0,0 +1,113 @@ +package tc.oc.api.docs.virtual; + +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import java.time.Instant; +import tc.oc.api.annotations.Serialize; +import tc.oc.api.docs.PlayerId; + +public interface UserDoc { + + interface Partial extends PartialModel {} + + @Serialize + interface Nickname extends Partial { + @Nullable String nickname(); + } + + @Serialize + interface Locale extends Partial { + @Nullable String mc_locale(); + } + + class Flair { + public String realm; + public String text; + public int priority; + } + + @Serialize + interface Identity extends PlayerId, Nickname { + @Nonnull UUID uuid(); + @Nonnull List minecraft_flair(); + } + + @Serialize + interface Trophies extends Partial { + List trophy_ids(); + } + + interface License { + + @Serialize + interface Kill extends Document { + @Nonnull String victim_id(); + boolean friendly(); + } + + @Serialize + interface Stats extends Partial { + @Nonnull List tnt_license_kills(); + } + + interface Request extends Stats { + @Serialize @Nullable Instant requested_tnt_license_at(); + + default boolean requestedTntLicense() { + return requested_tnt_license_at() != null; + } + } + + interface Grant extends Stats { + @Serialize @Nullable Instant granted_tnt_license_at(); + + default boolean hasTntLicense() { + return granted_tnt_license_at() != null; + } + } + + interface Complete extends Request, Grant {} + } + + /** + * Stuff we get from the API on login, and keep around for plugins to use + */ + @Serialize + interface Login extends Identity, Locale, Trophies, License.Complete { + int raindrops(); + String mc_last_sign_in_ip(); + @Nullable Date trial_expires_at(); + Map> mc_permissions_by_realm(); + Map> mc_settings_by_profile(); + Map classes(); + Set friends(); + Map> recent_match_joins_by_family_id(); // Reverse-chronological order + int enemy_kills(); + } + + /** + * Stuff we learn from the client on login, and report to the API + */ + @Serialize + interface ClientDetails extends Partial { + String mc_client_version(); + String skin_blob(); // Base64 encoded thing returned from Skin#getData() + } + + enum ResourcePackStatus { + // MUST match org.bukit.ResourcePackStatus + ACCEPTED, DECLINED, LOADED, FAILED + } + + @Serialize + interface ResourcePackResponse extends Partial { + UserDoc.ResourcePackStatus resource_pack_status(); + } +} diff --git a/API/api/src/main/java/tc/oc/api/docs/virtual/WhisperDoc.java b/API/api/src/main/java/tc/oc/api/docs/virtual/WhisperDoc.java new file mode 100644 index 0000000..fa96e7b --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/docs/virtual/WhisperDoc.java @@ -0,0 +1,29 @@ +package tc.oc.api.docs.virtual; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import java.time.Instant; +import tc.oc.api.annotations.Serialize; +import tc.oc.api.docs.PlayerId; + +public interface WhisperDoc { + interface Partial extends PartialModel {} + + @Serialize + interface Complete extends Delivery, Model { + @Nonnull String family(); + @Nonnull String server_id(); + @Nonnull Instant sent(); + @Nonnull PlayerId sender_uid(); + @Nullable String sender_nickname(); + @Nonnull PlayerId recipient_uid(); + @Nullable String recipient_specified(); + @Nonnull String content(); + } + + @Serialize + interface Delivery extends Partial, Model { + boolean delivered(); + } +} diff --git a/API/api/src/main/java/tc/oc/api/document/Accessor.java b/API/api/src/main/java/tc/oc/api/document/Accessor.java new file mode 100644 index 0000000..3c6cf6d --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/document/Accessor.java @@ -0,0 +1,84 @@ +package tc.oc.api.document; + +import java.lang.reflect.AccessibleObject; +import java.lang.reflect.Member; +import java.lang.reflect.Type; +import javax.annotation.Nullable; + +import com.google.common.reflect.TypeToken; +import tc.oc.api.docs.virtual.Document; + +/** + * Metadata for an accessor of a {@link Document} property of type {@link T}. + * This object wraps a particular field or method declared in a particular + * class. A property can have multiple accessors e.g. if they override each other. + */ +public interface Accessor { + + /** + * Metadata of the {@link Document} that declares this property + */ + DocumentMeta document(); + + /** + * Serialized name of the property + */ + String name(); + + /** + * Generic type of the property + */ + Type type(); + + boolean isPrimitive(); + + /** + * Type of the property in the context of the given document type. + * If this property type depends on type parameters declared on the + * document, they will be resolved using the actual type arguments + * of the given document type. + * + * For example, if a document is declared {@code Doc} and it has a + * property {@code List things;}, then calling this method on the + * things accessor with argument {@code Doc} would return + * {@code List}. + */ + Type resolvedType(Type documentType); + + Type resolvedType(TypeToken documentType); + + /** + * Raw type of the property + */ + Class rawType(); + + Class boxType(); + + /** + * Java reflection API handle for the wrapped accessor + */ + M member(); + + /** + * Accessor for the same property from an ancestor document that this accessor overrides. + */ + @Nullable Accessor override(); + + /** + * Is the property allowed to be null? This is determined from @Nullable and @Nonnull + * annotations on the wrapped member. Overrides inherit the nullability of their parent, + * and can override it with their own annotation. Nulls are allowed by default if no + * annotations are present in the property ancestry. + */ + boolean isNullable(); + + /** + * Is this accessor implemented in the given class? This is true only if + * the wrapped member exists on the given class, and is not an abstract method. + */ + boolean isImplemented(Class type); + + boolean hasDefault(); + + T validate(T value); +} diff --git a/API/api/src/main/java/tc/oc/api/document/BaseAccessor.java b/API/api/src/main/java/tc/oc/api/document/BaseAccessor.java new file mode 100644 index 0000000..9501fc1 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/document/BaseAccessor.java @@ -0,0 +1,116 @@ +package tc.oc.api.document; + +import java.lang.reflect.AccessibleObject; +import java.lang.reflect.Member; +import java.lang.reflect.Type; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import com.google.common.reflect.TypeToken; +import tc.oc.api.docs.virtual.Document; +import tc.oc.commons.core.reflect.Types; + +import static com.google.common.base.Preconditions.checkNotNull; + +public abstract class BaseAccessor implements Accessor { + + private final DocumentRegistry registry; + private DocumentMeta document; + private @Nullable Accessor override; + + protected BaseAccessor(DocumentRegistry registry) { + this.registry = checkNotNull(registry); + } + + @Override + public DocumentMeta document() { + if(document == null) { + this.document = registry.getMeta((Class) member().getDeclaringClass()); + + Accessor override = null; + for(DocumentMeta ancestor : document.ancestors()) { + if(ancestor != document) { + override = getOverrideIn(ancestor); + if(override != null) break; + } + } + this.override = override; + } + return document; + } + + @Override + public String name() { + return member().getName(); + } + + @Override + public Type resolvedType(Type documentType) { + return resolvedType(TypeToken.of(documentType)); + } + + @Override + public Type resolvedType(TypeToken documentType) { + return documentType.resolveType(type()).getType(); + } + + @Override + public @Nullable Accessor override() { + document(); + return override; + } + + protected abstract @Nullable Accessor getOverrideIn(DocumentMeta ancestor); + + @Override + public boolean isPrimitive() { + final Type type = type(); + return type instanceof Class && ((Class) type).isPrimitive(); + } + + @Override public Class boxType() { + return isPrimitive() ? Types.box(rawType()) + : rawType(); + } + + @Override + public boolean isNullable() { + if(isPrimitive()) return false; + + { + final AccessibleObject member = member(); + if(member.getAnnotation(Nullable.class) != null) return true; + if(member.getAnnotation(Nonnull.class) != null) return false; + } + { + final Member member = member(); + if(member.getDeclaringClass().getAnnotation(Nullable.class) != null) return true; + if(member.getDeclaringClass().getAnnotation(Nonnull.class) != null) return false; + } + + if(override() != null) return override().isNullable(); + + return true; + } + + @Override + public boolean hasDefault() { + return isNullable() || isImplemented(document().type()) || isImplemented(document().baseType()); + } + + @Override + public T validate(T value) { + if(value == null) { + if(!isNullable()) { + throw new NullPointerException("null value for non-nullable property " + name()); + } + } else { + if(!boxType().isInstance(value)) { + throw new ClassCastException("value of type " + value.getClass().getName() + + " is not assignable to property " + name() + " of type " + rawType().getName()); + } + } + + return value; + } +} diff --git a/API/api/src/main/java/tc/oc/api/document/DocumentGenerator.java b/API/api/src/main/java/tc/oc/api/document/DocumentGenerator.java new file mode 100644 index 0000000..0a5e355 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/document/DocumentGenerator.java @@ -0,0 +1,9 @@ +package tc.oc.api.document; + +import java.util.Map; + +import tc.oc.api.docs.virtual.Document; + +public interface DocumentGenerator { + T instantiate(DocumentMeta meta, Document base, Map data); +} diff --git a/API/api/src/main/java/tc/oc/api/document/DocumentMeta.java b/API/api/src/main/java/tc/oc/api/document/DocumentMeta.java new file mode 100644 index 0000000..2e52a45 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/document/DocumentMeta.java @@ -0,0 +1,101 @@ +package tc.oc.api.document; + +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Iterables; +import tc.oc.api.annotations.Serialize; +import tc.oc.api.docs.virtual.Document; + +/** + * Meta-info about a {@link Document} subtype, including all property {@link Accessor}s. + */ +public class DocumentMeta { + private final Class type; + private final ImmutableList> ancestors; + private final Class baseType; + private final ImmutableMap getters; + private final ImmutableMap setters; + + public DocumentMeta(Class type, List> ancestors, Class baseType, Map getters, Map setters) { + this.type = type; + this.ancestors = ImmutableList.copyOf(Iterables.concat(Collections.singleton(this), ancestors)); + this.baseType = baseType; + this.getters = ImmutableMap.copyOf(getters); + this.setters = ImmutableMap.copyOf(setters); + } + + @Override + public String toString() { + return getClass().getSimpleName() + "<" + type() + ">"; + } + + /** + * The {@link Document} subtype described by this metadata + */ + public Class type() { + return type; + } + + /** + * All ancestor {@link Document}s of this document, in resolution order. + * This list includes every supertype of this document that is also a subtype + * of {@link Document}, including this document itself. + * + * This list can include both classes and interfaces, which are flattened into + * a single list using the C3 linearization algorithm. Classes always come before + * interfaces in the list. + */ + public ImmutableList> ancestors() { + return ancestors; + } + + /** + * Best concrete base class to extend when implementing this document + * (used by the document generator) + */ + public Class baseType() { + return baseType; + } + + /** + * All property getters visible on this document type, + * including inherited getters that are not overridden. + */ + public ImmutableMap getters() { + return getters; + } + + /** + * All property setters visible on this document type, + * including inherited setters that are not overridden. + */ + public ImmutableMap setters() { + return setters; + } + + private static boolean isSerialized(AnnotatedElement element, boolean def) { + final Serialize annotation = element.getAnnotation(Serialize.class); + return annotation != null ? annotation.value() : def; + } + + public static Iterable serializedMembers(Class type, Iterable members) { + final boolean def = isSerialized(type, false); + return Iterables.filter(members, member -> isSerialized(member, def)); + } + + public static Iterable serializedMethods(Class type) { + return serializedMembers(type, Arrays.asList(type.getDeclaredMethods())); + } + + public static Iterable serializedFields(Class type) { + return serializedMembers(type, Arrays.asList(type.getDeclaredFields())); + } +} diff --git a/API/api/src/main/java/tc/oc/api/document/DocumentRegistry.java b/API/api/src/main/java/tc/oc/api/document/DocumentRegistry.java new file mode 100644 index 0000000..0f10d19 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/document/DocumentRegistry.java @@ -0,0 +1,271 @@ +package tc.oc.api.document; + +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.Field; +import java.lang.reflect.Member; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.logging.Level; +import java.util.logging.Logger; +import javax.annotation.Nullable; +import javax.inject.Inject; +import javax.inject.Singleton; + +import com.google.api.client.repackaged.com.google.common.base.Joiner; +import com.google.common.base.Function; +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Lists; +import com.google.common.collect.Maps; +import com.google.gson.annotations.SerializedName; +import com.google.inject.Injector; +import tc.oc.api.docs.BasicDeletableModel; +import tc.oc.api.docs.BasicModel; +import tc.oc.api.docs.PlayerId; +import tc.oc.api.docs.SimplePlayerId; +import tc.oc.api.docs.SimpleUserId; +import tc.oc.api.docs.UserId; +import tc.oc.api.docs.virtual.BasicDocument; +import tc.oc.api.docs.virtual.DeletableModel; +import tc.oc.api.docs.virtual.Document; +import tc.oc.api.docs.virtual.Model; +import tc.oc.api.exceptions.SerializationException; +import tc.oc.commons.core.logging.Loggers; +import tc.oc.commons.core.reflect.Types; +import tc.oc.commons.core.util.C3; +import tc.oc.commons.core.util.MapUtils; + +/** + * Cache of {@link DocumentMeta} records, populated on-demand when a {@link Document} + * is serialized or deserialized. + */ +@Singleton +public class DocumentRegistry { + + protected final Logger logger; + protected final DocumentGenerator generator; + protected final Injector injector; + + private final LoadingCache, DocumentMeta> cache = CacheBuilder.newBuilder().build( + new CacheLoader, DocumentMeta>() { + @Override + public DocumentMeta load(Class type) throws Exception { + return register(type); + } + } + ); + + @Inject DocumentRegistry(Loggers loggers, DocumentGenerator generator, Injector injector) { + this.generator = generator; + this.injector = injector; + this.logger = loggers.get(getClass()); + } + + /** + * Is the given document type directly instantiable? This is true only + * if the type is a non-abstract class with a default constructor. + */ + public boolean isInstantiable(Class type) { + if(type.isInterface() || Modifier.isAbstract(type.getModifiers())) return false; + try { + type.getDeclaredConstructor(); + return true; + } catch(NoSuchMethodException e) { + return false; + } + } + + public T instantiate(Class type, Map data) { + return instantiate(getMeta(type), data); + } + + public T instantiate(DocumentMeta meta, Map data) { + if(meta.type().isInterface()) { + // To create an interface document, choose the best base type, + // instantiate that, and then wrap it in a proxy that implements + // the rest of the properties. + return generator.instantiate(meta, instantiate(getMeta(meta.baseType()), data), data); + + } else if(isInstantiable(meta.type())) { + // If document type is directly instantiable, get an instance + // from the injector and use setters to initialize it. + final T doc = injector.getInstance(meta.type()); + for(Map.Entry entry : meta.setters().entrySet()) { + if(data.containsKey(entry.getKey())) { + entry.getValue().setUnchecked(doc, data.get(entry.getKey())); + } + } + return doc; + + } else { + throw new SerializationException("Document type " + meta.type().getName() + " is not instantiable"); + } + } + + public T copy(T original) { + final DocumentMeta meta = getMeta((Class) original.getClass()); + return instantiate(meta, ImmutableMap.copyOf(Maps.transformValues(meta.getters(), getter -> getter.get(original)))); + } + + /** + * Get (or create) the metadata for the given {@link Document} type. + */ + public DocumentMeta getMeta(Class type) { + return cache.getUnchecked(type); + } + + private DocumentMeta register(final Class type) { + logger.fine("Registering serializable type " + type); + + // Find property accessors declared directly on the given document + final Map getters = new HashMap<>(); + final Map setters = new HashMap<>(); + for(Method method : DocumentMeta.serializedMethods(type)) { + registerMethod(getters, setters, method); + } + for(Field field : DocumentMeta.serializedFields(type)) { + registerField(getters, setters, field); + } + + // Find the immediate supertypes of the document + final List> parents = new ArrayList<>(); + for(Class parent : Types.parents(type)) { + if(Document.class.isAssignableFrom(parent)) { + parents.add((DocumentMeta) getMeta(parent.asSubclass(Document.class))); + } + } + + // Merge all ancestors into a single list + final List> ancestors = ImmutableList.copyOf( + C3.merge( + Lists.transform( + parents, + (Function, Collection>>) DocumentMeta::ancestors + ) + ) + ); + + if(logger.isLoggable(Level.FINE)) { + logger.fine("Linearized ancestors for " + type + ": " + Joiner.on(", ").join(ancestors)); + } + + // Copy inherited accessors from ancestor documents + for(DocumentMeta ancestor : ancestors) { + MapUtils.putAbsent(getters, ancestor.getters()); + MapUtils.putAbsent(setters, ancestor.setters()); + } + + return new DocumentMeta<>(type, ancestors, bestBaseClass(type), getters, setters); + } + + private static String serializedName(Member member) { + if(member instanceof AnnotatedElement) { + SerializedName nameAnnot = ((AnnotatedElement) member).getAnnotation(SerializedName.class); + if(nameAnnot != null) return nameAnnot.value(); + } + return member.getName(); + } + + private static @Nullable Type getterType(Method method) { + if(method.getGenericParameterTypes().length == 0 && method.getGenericReturnType() != Void.TYPE) { + return method.getGenericReturnType(); + } + return null; + } + + private static @Nullable Type setterType(Method method) { + if(method.getGenericParameterTypes().length == 1) { + return method.getGenericParameterTypes()[0]; + } + return null; + } + + private void registerMethod(Map getters, Map setters, Method method) { + if(Modifier.isStatic(method.getModifiers())) return; + + final String name = serializedName(method); + boolean accessor = false; + + if(getterType(method) != null) { + accessor = true; + if(!getters.containsKey(name)) { + if(logger.isLoggable(Level.FINE)) { + logger.fine(" " + name + " -- get --> " + method); + } + getters.put(name, new GetterMethod(this, method)); + } + } + + if(setterType(method) != null) { + accessor = true; + if(!setters.containsKey(name)) { + if(logger.isLoggable(Level.FINE)) { + logger.fine(" " + name + " -- set --> " + method); + } + setters.put(name, new SetterMethod(this, method)); + } + } + + if(!accessor) { + throw new SerializationException("Serialized method " + method + " is not a valid getter or setter"); + } + } + + private void registerField(Map getters, Map setters, Field field) { + if(Modifier.isTransient(field.getModifiers()) || + Modifier.isStatic(field.getModifiers()) || + field.isSynthetic() || + field.isEnumConstant()) return; + + final String name = serializedName(field); + final boolean gettable = !getters.containsKey(name); + final boolean settable = !setters.containsKey(name); + + if(gettable || settable) { + if(logger.isLoggable(Level.FINE)) { + String access; + if(gettable && settable) { + access = "get/set"; + } else if(gettable) { + access = "get"; + } else { + access = "set"; + } + logger.fine(" " + name + " -- " + access + " --> " + field); + } + + if(gettable) { + getters.put(name, new FieldGetter(this, field)); + } + + if(settable) { + setters.put(name, new FieldSetter(this, field)); + } + } + } + + // TODO: This could could be done in a more general way i.e. search + // the registry for the best existing implementation to inherit from. + public Class bestBaseClass(Class type) { + if(PlayerId.class.isAssignableFrom(type)) { + return SimplePlayerId.class; + } else if(UserId.class.isAssignableFrom(type)) { + return SimpleUserId.class; + } else if(DeletableModel.class.isAssignableFrom(type)) { + return BasicDeletableModel.class; + } else if(Model.class.isAssignableFrom(type)) { + return BasicModel.class; + } else { + return BasicDocument.class; + } + } +} diff --git a/API/api/src/main/java/tc/oc/api/document/DocumentSerializer.java b/API/api/src/main/java/tc/oc/api/document/DocumentSerializer.java new file mode 100644 index 0000000..6da917b --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/document/DocumentSerializer.java @@ -0,0 +1,104 @@ +package tc.oc.api.document; + +import java.lang.reflect.Type; +import java.util.Map; +import java.util.logging.Logger; +import javax.inject.Inject; +import javax.inject.Singleton; + +import com.google.common.collect.ImmutableMap; +import com.google.common.reflect.TypeToken; +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; +import com.google.gson.JsonSyntaxException; +import com.google.inject.CreationException; +import com.google.inject.ProvisionException; +import tc.oc.api.docs.virtual.Document; +import tc.oc.api.document.DocumentMeta; +import tc.oc.api.document.DocumentRegistry; +import tc.oc.api.document.Getter; +import tc.oc.api.exceptions.SerializationException; +import tc.oc.commons.core.logging.Loggers; + +@Singleton +public class DocumentSerializer implements JsonSerializer, JsonDeserializer { + + protected final Logger logger; + protected final DocumentRegistry documentRegistry; + + @Inject DocumentSerializer(Loggers loggers, DocumentRegistry documentRegistry) { + this.logger = loggers.get(getClass()); + this.documentRegistry = documentRegistry; + } + + @Override + public JsonElement serialize(Document document, Type documentType, JsonSerializationContext context) { + final JsonObject documentJson = new JsonObject(); + final DocumentMeta documentMeta = documentRegistry.getMeta(document.getClass()); + final TypeToken documentTypeToken = TypeToken.of(documentType); + + for(Map.Entry entry : documentMeta.getters().entrySet()) { + final String name = entry.getKey(); + final Getter getter = entry.getValue(); + final Type resolvedType = getter.resolvedType(documentTypeToken); + + final Object value; + try { + value = getter.get(document); + } catch(Exception e) { + throw new SerializationException("Exception reading property " + resolvedType + " " + documentType + "." + name, e); + } + + try { + documentJson.add(name, context.serialize(value, resolvedType)); + } catch(Exception e) { + throw new SerializationException("Exception serializing property " + resolvedType + " " + documentType + "." + name + " = " + value, e); + } + } + + return documentJson; + } + + @Override + public Document deserialize(JsonElement rawJson, Type documentType, JsonDeserializationContext context) throws JsonParseException { + final TypeToken documentTypeToken = TypeToken.of(documentType); + final DocumentMeta documentMeta = documentRegistry.getMeta(documentTypeToken.getRawType()); + final Class instantiableType = documentMeta.type(); + + if(!(rawJson instanceof JsonObject)) { + throw new JsonSyntaxException("Expected JSON object while deserializing " + instantiableType.getName() + ", not " + rawJson.getClass().getSimpleName()); + } + final JsonObject documentJson = (JsonObject) rawJson; + final ImmutableMap.Builder builder = ImmutableMap.builder(); + + for(Map.Entry entry : documentMeta.getters().entrySet()) { + final String name = entry.getKey(); + final Getter getter = entry.getValue(); + final Type resolvedType = getter.resolvedType(documentTypeToken); + final JsonElement propertyJson = documentJson.get(name); + + try { + if(propertyJson == null || propertyJson.isJsonNull()) { + if(!getter.isNullable()) { + throw new NullPointerException("Missing value for non-nullable property " + name); + } + } else { + builder.put(name, context.deserialize(propertyJson, resolvedType)); + } + } catch(Exception e) { + throw new SerializationException("Exception deserializing property " + resolvedType + " " + documentType + "." + name + " = " + propertyJson, e); + } + } + + try { + return documentRegistry.instantiate(documentMeta, builder.build()); + } catch(ProvisionException | CreationException e) { + throw new SerializationException("Exception instantiating document " + instantiableType.getName(), e); + } + } +} diff --git a/API/api/src/main/java/tc/oc/api/document/DocumentsManifest.java b/API/api/src/main/java/tc/oc/api/document/DocumentsManifest.java new file mode 100644 index 0000000..4bf3bb2 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/document/DocumentsManifest.java @@ -0,0 +1,19 @@ +package tc.oc.api.document; + +import tc.oc.api.docs.virtual.Document; +import tc.oc.api.serialization.GsonBinder; +import tc.oc.commons.core.inject.HybridManifest; + +public class DocumentsManifest extends HybridManifest { + + @Override + protected void configure() { + bindAndExpose(DocumentSerializer.class); + bind(DocumentRegistry.class); + bind(DocumentGenerator.class).to(ProxyDocumentGenerator.class); + + new GsonBinder(publicBinder()) + .bindHiearchySerializer(Document.class) + .to(DocumentSerializer.class); + } +} diff --git a/API/api/src/main/java/tc/oc/api/document/FieldAccessor.java b/API/api/src/main/java/tc/oc/api/document/FieldAccessor.java new file mode 100644 index 0000000..de6ce45 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/document/FieldAccessor.java @@ -0,0 +1,35 @@ +package tc.oc.api.document; + +import java.lang.reflect.Field; +import java.lang.reflect.Type; + +public abstract class FieldAccessor extends BaseAccessor { + + private final Field field; + + public FieldAccessor(DocumentRegistry registry, Field field) { + super(registry); + this.field = field; + field.setAccessible(true); + } + + @Override + public Type type() { + return field.getGenericType(); + } + + @Override + public Class rawType() { + return (Class) field.getType(); + } + + @Override + public Field member() { + return field; + } + + @Override + public boolean isImplemented(Class type) { + return field.getDeclaringClass().isAssignableFrom(type); + } +} diff --git a/API/api/src/main/java/tc/oc/api/document/FieldGetter.java b/API/api/src/main/java/tc/oc/api/document/FieldGetter.java new file mode 100644 index 0000000..89b8a1d --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/document/FieldGetter.java @@ -0,0 +1,30 @@ +package tc.oc.api.document; + +import java.lang.reflect.Field; +import javax.annotation.Nullable; + +import tc.oc.commons.core.util.ExceptionUtils; + +/** + * Property getter that wraps a field + */ +public class FieldGetter extends FieldAccessor implements Getter { + + public FieldGetter(DocumentRegistry registry, Field field) { + super(registry, field); + } + + @Override + protected @Nullable Accessor getOverrideIn(DocumentMeta ancestor) { + return ancestor.getters().get(name()); + } + + @Override + public T get(Object obj) { + try { + return validate((T) member().get(obj)); + } catch(IllegalAccessException e) { + throw ExceptionUtils.propagate(e); + } + } +} diff --git a/API/api/src/main/java/tc/oc/api/document/FieldSetter.java b/API/api/src/main/java/tc/oc/api/document/FieldSetter.java new file mode 100644 index 0000000..c25f0ee --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/document/FieldSetter.java @@ -0,0 +1,40 @@ +package tc.oc.api.document; + +import java.lang.reflect.Field; +import java.util.concurrent.ExecutionException; +import javax.annotation.Nullable; + +import com.google.common.util.concurrent.UncheckedExecutionException; + +/** + * Property setter that wraps a field + */ +public class FieldSetter extends FieldAccessor implements Setter { + + public FieldSetter(DocumentRegistry registry, Field field) { + super(registry, field); + } + + @Override + protected @Nullable Accessor getOverrideIn(DocumentMeta ancestor) { + return ancestor.setters().get(name()); + } + + @Override + public void setUnchecked(Object obj, T value) { + try { + set(obj, value); + } catch(ExecutionException e) { + throw new UncheckedExecutionException(e.getCause()); + } + } + + @Override + public void set(Object obj, T value) throws ExecutionException { + try { + member().set(obj, validate(value)); + } catch(IllegalAccessException e) { + throw new ExecutionException(e); + } + } +} diff --git a/API/api/src/main/java/tc/oc/api/document/Getter.java b/API/api/src/main/java/tc/oc/api/document/Getter.java new file mode 100644 index 0000000..b0438fe --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/document/Getter.java @@ -0,0 +1,18 @@ +package tc.oc.api.document; + +import java.util.concurrent.ExecutionException; + +import tc.oc.api.docs.virtual.Document; + +/** + * Metadata for an accessor that can read the value of a {@link Document} property. + */ +public interface Getter extends Accessor { + + /** + * Get the value of this property on the given document. + * @throws ExecutionException if a checked exception was thrown while trying to read the value, + * or if the property is not accessible on the given object. + */ + T get(Object obj); +} diff --git a/API/api/src/main/java/tc/oc/api/document/GetterMethod.java b/API/api/src/main/java/tc/oc/api/document/GetterMethod.java new file mode 100644 index 0000000..776cdba --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/document/GetterMethod.java @@ -0,0 +1,62 @@ +package tc.oc.api.document; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Type; +import javax.annotation.Nullable; + +import tc.oc.api.docs.virtual.Document; +import tc.oc.commons.core.reflect.Methods; +import tc.oc.commons.core.util.ExceptionUtils; + +import static com.google.common.base.Preconditions.checkNotNull; + +/** + * Metadata for a getter method of a {@link Document} property. + * + * The wrapped method takes no parameters and returns the value of the property. + */ +public class GetterMethod extends BaseAccessor implements Getter { + + private final Method method; + + public GetterMethod(DocumentRegistry registry, Method method) { + super(registry); + this.method = checkNotNull(method); + method.setAccessible(true); + } + + @Override + public Method member() { + return method; + } + + @Override + public Type type() { + return method.getGenericReturnType(); + } + + @Override + public Class rawType() { + return (Class) method.getReturnType(); + } + + @Override + protected @Nullable Accessor getOverrideIn(DocumentMeta ancestor) { + return ancestor.getters().get(name()); + } + + @Override + public boolean isImplemented(Class type) { + return Methods.respondsTo(type, method); + } + + @Override + public T get(Object obj) { + try { + return validate((T) member().invoke(obj)); + } catch(IllegalAccessException | InvocationTargetException e) { + throw ExceptionUtils.propagate(e); + } + } +} diff --git a/API/api/src/main/java/tc/oc/api/document/ProxyDocumentGenerator.java b/API/api/src/main/java/tc/oc/api/document/ProxyDocumentGenerator.java new file mode 100644 index 0000000..17b438a --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/document/ProxyDocumentGenerator.java @@ -0,0 +1,134 @@ +package tc.oc.api.document; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Stream; + +import com.google.common.cache.LoadingCache; +import tc.oc.api.docs.virtual.Document; +import tc.oc.api.docs.virtual.Model; +import tc.oc.api.exceptions.SerializationException; +import tc.oc.commons.core.inspect.Inspectable; +import tc.oc.commons.core.inspect.InspectableProperty; +import tc.oc.commons.core.reflect.MethodHandleUtils; +import tc.oc.commons.core.reflect.Methods; +import tc.oc.commons.core.stream.BiStream; +import tc.oc.commons.core.util.CacheUtils; + +public class ProxyDocumentGenerator implements DocumentGenerator { + + @Override + public T instantiate(DocumentMeta meta, Document base, Map data) { + // Validate data before creating the document + for(Map.Entry entry : meta.getters().entrySet()) { + final String name = entry.getKey(); + final Getter getter = entry.getValue(); + + if(data.containsKey(name)) { + getter.validate(data.get(name)); + } else if(!getter.hasDefault()) { + throw new IllegalArgumentException("Missing value for required property " + name); + } + } + + return new Invoker<>(meta, base, data).proxy; + } + + private static final Method Object_toString = Methods.declaredMethod(Object.class, "toString"); + private static final Method Inspectable_inspect = Methods.declaredMethod(Inspectable.class, "inspect"); + + private class Invoker implements InvocationHandler, Inspectable { + + final DocumentMeta meta; + final Map data; + final LoadingCache handles; + final T proxy; + + Invoker(DocumentMeta meta, Document base, Map data) { + this.meta = meta; + this.data = data; + + this.proxy = (T) Proxy.newProxyInstance(meta.type().getClassLoader(), new Class[]{meta.type(), Inspectable.class}, this); + + this.handles = CacheUtils.newCache(method -> { + if(method.getDeclaringClass().isAssignableFrom(Inspectable.class) && + !method.getDeclaringClass().isAssignableFrom(Object.class)) { + // Send Inspectable methods to this + return MethodHandles.lookup() + .unreflect(method) + .bindTo(this); + } else if(method.equals(Object_toString)) { + // Send toString to this.inspect() + return MethodHandles.lookup() + .unreflect(Inspectable_inspect) + .bindTo(this); + } + + final String name = method.getName(); + final Getter getter; + + // If method is a property getter, and we have a value for the property, + // return a constant method handle that just returns the value. + if(method.getParameterTypes().length == 0) { + getter = meta.getters().get(name); + if(getter != null && data.containsKey(name)) { + return MethodHandles.constant(method.getReturnType(), data.get(name)); + } + } else { + getter = null; + } + + // If the base class implements the method, call that one. + if(method.getDeclaringClass().isInstance(base)) { + return MethodHandles.lookup() + .unreflect(method) + .bindTo(base); + } + + // If method has a default implementation in the document interface, + // return a method handle that calls that implementation directly, + // with the proxy as target object. We can't just invoke the method + // the normal way, because the proxy would intercept it again. + if(method.isDefault()) { + return MethodHandleUtils.defaultMethodHandle(method) + .bindTo(proxy); + } + + // If the method is a nullable property, we can return null as a default value. + if(getter != null && getter.isNullable()) { + return MethodHandles.constant(method.getReturnType(), null); + } + + throw new SerializationException("No implementation for method '" + name + "'"); + }); + } + + @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + return handles.getUnchecked(method).invokeWithArguments(args); + } + + @Override + public String inspectType() { + return meta.type().getSimpleName(); + } + + @Override + public Optional inspectIdentity() { + if(proxy instanceof Model) { + return Optional.of(((Model) proxy)._id()); + } + return Optional.empty(); + } + + @Override + public Stream inspectableProperties() { + return BiStream.from(data) + .merge(InspectableProperty::of); + } + } +} diff --git a/API/api/src/main/java/tc/oc/api/document/Setter.java b/API/api/src/main/java/tc/oc/api/document/Setter.java new file mode 100644 index 0000000..21e7064 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/document/Setter.java @@ -0,0 +1,26 @@ +package tc.oc.api.document; + +import java.util.concurrent.ExecutionException; + +import com.google.common.util.concurrent.UncheckedExecutionException; +import tc.oc.api.docs.virtual.Document; + +/** + * Metadata for an accessor that can set the value of a {@link Document} property + */ +public interface Setter extends Accessor { + + /** + * Set the value of this property on the given document. + * @throws ExecutionException if a checked exception was thrown while trying to write the value, + * or if the property is not accessible on the given object. + */ + void set(Object obj, T value) throws ExecutionException; + + /** + * Set the value of this property on the given document. + * @throws UncheckedExecutionException if a checked exception was thrown while trying to write the value, + * or if the property is not accessible on the given object. + */ + void setUnchecked(Object obj, T value); +} diff --git a/API/api/src/main/java/tc/oc/api/document/SetterMethod.java b/API/api/src/main/java/tc/oc/api/document/SetterMethod.java new file mode 100644 index 0000000..210d877 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/document/SetterMethod.java @@ -0,0 +1,76 @@ +package tc.oc.api.document; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.lang.reflect.Type; +import java.util.concurrent.ExecutionException; +import javax.annotation.Nullable; + +import com.google.common.util.concurrent.UncheckedExecutionException; +import tc.oc.api.docs.virtual.Document; + +import static com.google.common.base.Preconditions.checkNotNull; + +/** + * Metadata for a setter method of a {@link Document} property. + * + * The wrapped method takes the new value as its single parameter. + */ +public class SetterMethod extends BaseAccessor implements Setter { + + private final Method method; + + public SetterMethod(DocumentRegistry registry, Method method) { + super(registry); + this.method = checkNotNull(method); + method.setAccessible(true); + } + + @Override + public Method member() { + return method; + } + + @Override + public Type type() { + return method.getGenericParameterTypes()[0]; + } + + @Override + public Class rawType() { + return (Class) method.getParameterTypes()[0]; + } + + @Override + protected @Nullable Accessor getOverrideIn(DocumentMeta ancestor) { + return ancestor.setters().get(name()); + } + + @Override + public boolean isImplemented(Class type) { + try { + return !Modifier.isAbstract(type.getMethod(method.getName(), method.getParameterTypes()[0]).getModifiers()); + } catch(NoSuchMethodException e) { + return false; + } + } + + @Override + public void setUnchecked(Object obj, T value) { + try { + set(obj, value); + } catch(ExecutionException e) { + throw new UncheckedExecutionException(e.getCause()); + } + } + + @Override + public void set(Object obj, T value) throws ExecutionException { + try { + method.invoke(obj, validate(value)); + } catch(IllegalAccessException | InvocationTargetException e) { + throw new ExecutionException(e); + } + } +} diff --git a/API/api/src/main/java/tc/oc/api/engagement/EngagementModelManifest.java b/API/api/src/main/java/tc/oc/api/engagement/EngagementModelManifest.java new file mode 100644 index 0000000..342d20c --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/engagement/EngagementModelManifest.java @@ -0,0 +1,17 @@ +package tc.oc.api.engagement; + +import com.google.inject.multibindings.OptionalBinder; +import tc.oc.api.docs.virtual.EngagementDoc; +import tc.oc.api.model.ModelBinders; +import tc.oc.commons.core.inject.HybridManifest; + +public class EngagementModelManifest extends HybridManifest implements ModelBinders { + + @Override + protected void configure() { + bindModel(EngagementDoc.class); + + OptionalBinder.newOptionalBinder(publicBinder(), EngagementService.class) + .setDefault().to(LocalEngagementService.class); + } +} diff --git a/API/api/src/main/java/tc/oc/api/engagement/EngagementService.java b/API/api/src/main/java/tc/oc/api/engagement/EngagementService.java new file mode 100644 index 0000000..13cdf2a --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/engagement/EngagementService.java @@ -0,0 +1,12 @@ +package tc.oc.api.engagement; + +import java.util.Collection; + +import com.google.common.util.concurrent.ListenableFuture; +import tc.oc.api.docs.virtual.EngagementDoc; +import tc.oc.api.message.types.Reply; + +public interface EngagementService { + + ListenableFuture updateMulti(Collection engagements); +} diff --git a/API/api/src/main/java/tc/oc/api/engagement/EngagementUpdateRequest.java b/API/api/src/main/java/tc/oc/api/engagement/EngagementUpdateRequest.java new file mode 100644 index 0000000..8d3f3c0 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/engagement/EngagementUpdateRequest.java @@ -0,0 +1,19 @@ +package tc.oc.api.engagement; + +import java.util.Collection; + +import tc.oc.api.annotations.Serialize; +import tc.oc.api.docs.virtual.EngagementDoc; +import tc.oc.api.message.Message; +import tc.oc.api.queue.MessageDefaults; + +@MessageDefaults.RoutingKey("engagements") +@MessageDefaults.Persistent(true) +public class EngagementUpdateRequest implements Message { + + @Serialize public final Collection engagements; + + public EngagementUpdateRequest(Collection engagements) { + this.engagements = engagements; + } +} diff --git a/API/api/src/main/java/tc/oc/api/engagement/LocalEngagementService.java b/API/api/src/main/java/tc/oc/api/engagement/LocalEngagementService.java new file mode 100644 index 0000000..e4ef01e --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/engagement/LocalEngagementService.java @@ -0,0 +1,17 @@ +package tc.oc.api.engagement; + +import java.util.Collection; + +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import tc.oc.api.docs.virtual.EngagementDoc; +import tc.oc.api.engagement.EngagementService; +import tc.oc.api.message.types.Reply; + +public class LocalEngagementService implements EngagementService { + + @Override + public ListenableFuture updateMulti(Collection engagements) { + return Futures.immediateFuture(Reply.SUCCESS); + } +} diff --git a/API/api/src/main/java/tc/oc/api/exceptions/ApiException.java b/API/api/src/main/java/tc/oc/api/exceptions/ApiException.java new file mode 100644 index 0000000..e6287f8 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/exceptions/ApiException.java @@ -0,0 +1,53 @@ +package tc.oc.api.exceptions; + +import javax.annotation.Nullable; + +import tc.oc.api.message.types.Reply; +import tc.oc.commons.core.util.ArrayUtils; + +/** + * Thrown when an API call has an exceptional response. This abstracts away HTTP + * status codes so we can use other protocols for the API. + */ +public class ApiException extends Exception { + + private @Nullable StackTraceElement[] originalTrace; + private @Nullable StackTraceElement[] callSite; + private final @Nullable Reply reply; + + public ApiException(String message, @Nullable Reply reply) { + this(message, null, null, reply); + } + + public ApiException(String message, @Nullable StackTraceElement[] callSite) { + this(message, null, callSite); + } + + public ApiException(String message, @Nullable Throwable cause, @Nullable StackTraceElement[] callSite) { + this(message, cause, callSite, null); + } + + public ApiException(String message, @Nullable Throwable cause, @Nullable StackTraceElement[] callSite, @Nullable Reply reply) { + super(message, cause); + this.reply = reply; + setCallSite(callSite); + } + + public @Nullable Reply getReply() { + return reply; + } + + public @Nullable StackTraceElement[] getCallSite() { + return callSite; + } + + public void setCallSite(@Nullable StackTraceElement[] callSite) { + this.callSite = callSite; + if(callSite != null) { + if(originalTrace == null) { + originalTrace = getStackTrace(); + } + setStackTrace(ArrayUtils.append(originalTrace, callSite)); + } + } +} diff --git a/API/api/src/main/java/tc/oc/api/exceptions/ApiNotConnected.java b/API/api/src/main/java/tc/oc/api/exceptions/ApiNotConnected.java new file mode 100644 index 0000000..3b025a6 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/exceptions/ApiNotConnected.java @@ -0,0 +1,16 @@ +package tc.oc.api.exceptions; + +/** + * Thrown when you try to do something that requires an API connection, + * but the API is not currently connected. + */ +public class ApiNotConnected extends IllegalStateException { + + public ApiNotConnected() { + this("No API connection"); + } + + public ApiNotConnected(String s) { + super(s); + } +} diff --git a/API/api/src/main/java/tc/oc/api/exceptions/Conflict.java b/API/api/src/main/java/tc/oc/api/exceptions/Conflict.java new file mode 100644 index 0000000..2ae5ae2 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/exceptions/Conflict.java @@ -0,0 +1,14 @@ +package tc.oc.api.exceptions; + +import javax.annotation.Nullable; + +import tc.oc.api.message.types.Reply; + +/** + * An API request is invalid based on the current state of whatever it is manipulating + */ +public class Conflict extends ApiException { + public Conflict(String message, @Nullable Reply reply) { + super(message, reply); + } +} diff --git a/API/api/src/main/java/tc/oc/api/exceptions/Forbidden.java b/API/api/src/main/java/tc/oc/api/exceptions/Forbidden.java new file mode 100644 index 0000000..6a58bb7 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/exceptions/Forbidden.java @@ -0,0 +1,14 @@ +package tc.oc.api.exceptions; + +import javax.annotation.Nullable; + +import tc.oc.api.message.types.Reply; + +/** + * An API request was not allowed + */ +public class Forbidden extends ApiException { + public Forbidden(String message, @Nullable Reply reply) { + super(message, reply); + } +} diff --git a/API/api/src/main/java/tc/oc/api/exceptions/NotFound.java b/API/api/src/main/java/tc/oc/api/exceptions/NotFound.java new file mode 100644 index 0000000..34cdc1a --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/exceptions/NotFound.java @@ -0,0 +1,23 @@ +package tc.oc.api.exceptions; + +import javax.annotation.Nullable; + +import tc.oc.api.message.types.Reply; + +/** + * Something was referenced through the API that does not exist + */ +public class NotFound extends ApiException { + + public NotFound() { + this(null); + } + + public NotFound(@Nullable String message) { + this(message, null); + } + + public NotFound(@Nullable String message, @Nullable Reply reply) { + super(message, reply); + } +} diff --git a/API/api/src/main/java/tc/oc/api/exceptions/SerializationException.java b/API/api/src/main/java/tc/oc/api/exceptions/SerializationException.java new file mode 100644 index 0000000..c5f6f51 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/exceptions/SerializationException.java @@ -0,0 +1,19 @@ +package tc.oc.api.exceptions; + +public class SerializationException extends RuntimeException { + + public SerializationException() { + } + + public SerializationException(String message) { + super(message); + } + + public SerializationException(String message, Throwable cause) { + super(message, cause); + } + + public SerializationException(Throwable cause) { + super(cause); + } +} diff --git a/API/api/src/main/java/tc/oc/api/exceptions/UnknownMessageType.java b/API/api/src/main/java/tc/oc/api/exceptions/UnknownMessageType.java new file mode 100644 index 0000000..43f3c6d --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/exceptions/UnknownMessageType.java @@ -0,0 +1,20 @@ +package tc.oc.api.exceptions; + +/** + * Thrown when an unknown message type is received from an AMQP queue. + * This is usually not a problem. It could be a message on the fanout + * exchange not intended for the server, or a future message type + * sent during an upgrade. + */ +public class UnknownMessageType extends RuntimeException { + private final String type; + + public UnknownMessageType(String type) { + super("Unknown queue message of type '" + type + "'"); + this.type = type; + } + + public String getMessageType() { + return type; + } +} diff --git a/API/api/src/main/java/tc/oc/api/exceptions/UnmappedUserException.java b/API/api/src/main/java/tc/oc/api/exceptions/UnmappedUserException.java new file mode 100644 index 0000000..43c1a8c --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/exceptions/UnmappedUserException.java @@ -0,0 +1,18 @@ +package tc.oc.api.exceptions; + +public class UnmappedUserException extends IllegalStateException { + private final String username; + + public UnmappedUserException(String username) { + this.username = username; + } + + public String getUsername() { + return username; + } + + @Override + public String getMessage() { + return "No UserId stored for username \"" + this.username + "\""; + } +} diff --git a/API/api/src/main/java/tc/oc/api/exceptions/UnprocessableEntity.java b/API/api/src/main/java/tc/oc/api/exceptions/UnprocessableEntity.java new file mode 100644 index 0000000..50cb318 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/exceptions/UnprocessableEntity.java @@ -0,0 +1,14 @@ +package tc.oc.api.exceptions; + +import javax.annotation.Nullable; + +import tc.oc.api.message.types.Reply; + +/** + * HTTP 422 i.e. validation failed + */ +public class UnprocessableEntity extends ApiException { + public UnprocessableEntity(String message, @Nullable Reply reply) { + super(message, reply); + } +} diff --git a/API/api/src/main/java/tc/oc/api/games/ArenaStore.java b/API/api/src/main/java/tc/oc/api/games/ArenaStore.java new file mode 100644 index 0000000..dcbfd5a --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/games/ArenaStore.java @@ -0,0 +1,41 @@ +package tc.oc.api.games; + +import java.util.Set; +import javax.annotation.Nullable; +import javax.inject.Singleton; + +import com.google.common.collect.HashBasedTable; +import com.google.common.collect.HashMultimap; +import com.google.common.collect.SetMultimap; +import com.google.common.collect.Table; +import tc.oc.api.docs.Arena; +import tc.oc.api.model.ModelStore; + +@Singleton +public class ArenaStore extends ModelStore { + + private final SetMultimap byDatacenter = HashMultimap.create(); + private final Table byDatacenterAndGameId = HashBasedTable.create(); + + public Set byDatacenter(String datacenter) { + return byDatacenter.get(datacenter); + } + + public @Nullable Arena tryDatacenterAndGameId(String datacenter, String gameId) { + return byDatacenterAndGameId.get(datacenter, gameId); + } + + @Override + protected void unindex(Arena doc) { + super.unindex(doc); + byDatacenter.remove(doc.datacenter(), doc); + byDatacenterAndGameId.remove(doc.datacenter(), doc.game_id()); + } + + @Override + protected void reindex(Arena doc) { + super.reindex(doc); + byDatacenter.put(doc.datacenter(), doc); + byDatacenterAndGameId.put(doc.datacenter(), doc.game_id(), doc); + } +} diff --git a/API/api/src/main/java/tc/oc/api/games/GameModelManifest.java b/API/api/src/main/java/tc/oc/api/games/GameModelManifest.java new file mode 100644 index 0000000..0d64c34 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/games/GameModelManifest.java @@ -0,0 +1,35 @@ +package tc.oc.api.games; + +import tc.oc.api.docs.Arena; +import tc.oc.api.docs.Game; +import tc.oc.api.docs.Ticket; +import tc.oc.api.model.ModelBinders; +import tc.oc.commons.core.inject.HybridManifest; + +public class GameModelManifest extends HybridManifest implements ModelBinders { + + @Override + protected void configure() { + bindAndExpose(GameStore.class); + bindAndExpose(ArenaStore.class); + bindAndExpose(TicketStore.class); + + bindModel(Game.class, model -> { + model.bindStore().to(GameStore.class); + model.queryService().setDefault().to(model.nullQueryService()); + }); + + bindModel(Arena.class, model -> { + model.bindStore().to(ArenaStore.class); + model.queryService().setDefault().to(model.nullQueryService()); + }); + + bindModel(Ticket.class, model -> { + model.bindStore().to(TicketStore.class); + model.queryService().setBinding().to(TicketService.class); + }); + + publicBinder().forOptional(TicketService.class) + .setDefault().to(NullTicketService.class); + } +} diff --git a/API/api/src/main/java/tc/oc/api/games/GameStore.java b/API/api/src/main/java/tc/oc/api/games/GameStore.java new file mode 100644 index 0000000..0cff450 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/games/GameStore.java @@ -0,0 +1,10 @@ +package tc.oc.api.games; + +import javax.inject.Singleton; + +import tc.oc.api.docs.Game; +import tc.oc.api.model.ModelStore; + +@Singleton +public class GameStore extends ModelStore { +} diff --git a/API/api/src/main/java/tc/oc/api/games/NullTicketService.java b/API/api/src/main/java/tc/oc/api/games/NullTicketService.java new file mode 100644 index 0000000..566c459 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/games/NullTicketService.java @@ -0,0 +1,23 @@ +package tc.oc.api.games; + +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import tc.oc.api.docs.Ticket; +import tc.oc.api.message.types.CycleRequest; +import tc.oc.api.message.types.CycleResponse; +import tc.oc.api.message.types.PlayGameRequest; +import tc.oc.api.message.types.Reply; +import tc.oc.api.model.NullQueryService; + +class NullTicketService extends NullQueryService implements TicketService { + + @Override + public ListenableFuture requestPlay(PlayGameRequest request) { + return Futures.immediateFuture(Reply.FAILURE); + } + + @Override + public ListenableFuture requestCycle(CycleRequest request) { + return Futures.immediateFuture(CycleResponse.EMPTY); + } +} diff --git a/API/api/src/main/java/tc/oc/api/games/TicketService.java b/API/api/src/main/java/tc/oc/api/games/TicketService.java new file mode 100644 index 0000000..b54aff8 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/games/TicketService.java @@ -0,0 +1,16 @@ +package tc.oc.api.games; + +import com.google.common.util.concurrent.ListenableFuture; +import tc.oc.api.docs.Ticket; +import tc.oc.api.message.types.CycleRequest; +import tc.oc.api.message.types.CycleResponse; +import tc.oc.api.message.types.PlayGameRequest; +import tc.oc.api.message.types.Reply; +import tc.oc.api.model.QueryService; + +public interface TicketService extends QueryService { + + ListenableFuture requestPlay(PlayGameRequest request); + + ListenableFuture requestCycle(CycleRequest request); +} diff --git a/API/api/src/main/java/tc/oc/api/games/TicketStore.java b/API/api/src/main/java/tc/oc/api/games/TicketStore.java new file mode 100644 index 0000000..e2f2067 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/games/TicketStore.java @@ -0,0 +1,54 @@ +package tc.oc.api.games; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import javax.annotation.Nullable; +import javax.inject.Singleton; + +import com.google.common.collect.HashMultimap; +import com.google.common.collect.SetMultimap; +import tc.oc.api.docs.Arena; +import tc.oc.api.docs.PlayerId; +import tc.oc.api.docs.Ticket; +import tc.oc.api.model.ModelStore; + +@Singleton +public class TicketStore extends ModelStore { + + private final Map byUser = new HashMap<>(); + private final SetMultimap byArenaId = HashMultimap.create(); + private final SetMultimap byArenaIdQueued = HashMultimap.create(); + + public @Nullable Ticket tryUser(PlayerId playerId) { + return byUser.get(playerId); + } + + public Set byArena(Arena arena) { + return byArenaId.get(arena._id()); + } + + public Set queued(Arena arena) { + return byArenaIdQueued.get(arena._id()); + } + + @Override + protected void reindex(Ticket doc) { + super.reindex(doc); + byUser.put(doc.user(), doc); + byArenaId.put(doc.arena_id(), doc); + if(doc.server_id() == null) { + byArenaIdQueued.put(doc.arena_id(), doc); + } else { + byArenaIdQueued.remove(doc.arena_id(), doc); + } + } + + @Override + protected void unindex(Ticket doc) { + super.unindex(doc); + byUser.remove(doc.user()); + byArenaId.remove(doc.arena_id(), doc); + byArenaIdQueued.remove(doc.arena_id(), doc); + } +} diff --git a/API/api/src/main/java/tc/oc/api/http/HttpClient.java b/API/api/src/main/java/tc/oc/api/http/HttpClient.java new file mode 100644 index 0000000..f66209a --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/http/HttpClient.java @@ -0,0 +1,307 @@ +package tc.oc.api.http; + +import java.io.IOException; +import java.io.OutputStream; +import java.lang.reflect.Type; +import java.time.Duration; +import java.util.concurrent.Callable; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; +import java.util.logging.ConsoleHandler; +import java.util.logging.Level; +import java.util.logging.LogRecord; +import java.util.logging.Logger; +import javax.annotation.Nullable; +import javax.inject.Inject; +import javax.inject.Singleton; + +import com.google.api.client.http.AbstractHttpContent; +import com.google.api.client.http.GenericUrl; +import com.google.api.client.http.HttpBackOffIOExceptionHandler; +import com.google.api.client.http.HttpContent; +import com.google.api.client.http.HttpMethods; +import com.google.api.client.http.HttpRequest; +import com.google.api.client.http.HttpRequestFactory; +import com.google.api.client.http.HttpResponse; +import com.google.api.client.http.HttpTransport; +import com.google.api.client.http.javanet.NetHttpTransport; +import com.google.api.client.util.ExponentialBackOff; +import com.google.common.base.Charsets; +import com.google.common.reflect.TypeToken; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.ListeningExecutorService; +import com.google.common.util.concurrent.MoreExecutors; +import com.google.common.util.concurrent.ThreadFactoryBuilder; +import com.google.gson.Gson; +import com.google.gson.JsonParseException; +import tc.oc.api.connectable.Connectable; +import tc.oc.api.config.ApiConstants; +import tc.oc.api.exceptions.ApiException; +import tc.oc.api.exceptions.Conflict; +import tc.oc.api.exceptions.Forbidden; +import tc.oc.api.exceptions.NotFound; +import tc.oc.api.exceptions.UnprocessableEntity; +import tc.oc.api.message.types.Reply; +import tc.oc.api.serialization.JsonUtils; +import tc.oc.commons.core.concurrent.ExecutorUtils; +import tc.oc.commons.core.logging.Loggers; + +import static com.google.common.base.Preconditions.checkNotNull; + +@Singleton +public class HttpClient implements Connectable { + + private static final Duration SHUTDOWN_TIMEOUT = Duration.ofSeconds(10); + + protected final Logger logger; + + private final HttpClientConfiguration config; + private final ListeningExecutorService executorService; + private final JsonUtils jsonUtils; + private final Gson gson; + private final HttpRequestFactory requestFactory; + + @Inject HttpClient(Loggers loggers, HttpClientConfiguration config, JsonUtils jsonUtils, Gson gson) { + this.logger = loggers.get(getClass()); + this.config = checkNotNull(config, "config"); + this.jsonUtils = jsonUtils; + this.gson = gson; + ThreadFactory threadFactory = new ThreadFactoryBuilder().setNameFormat("API HTTP Executor").build(); + if (config.getThreads() > 0) { + this.executorService = MoreExecutors.listeningDecorator(Executors.newFixedThreadPool(config.getThreads())); + } else { + this.executorService = MoreExecutors.listeningDecorator(Executors.newCachedThreadPool(threadFactory)); + } + + this.requestFactory = this.createRequestFactory(); + + // By default the google http client is very noisy on http errors, + // spitting out a long stack trace every time one try of a request + // fails. Disabling the parent handlers (removes default console + // handling) and adding our own handler that will only show the short + // message makes the error output much more manageable. + // See HttpRequest#986 for the offending line and HttpTransport#81 for + // the logger definition (it's package private so we can't access it + // directly). + final Logger httpLogger = Logger.getLogger(HttpTransport.class.getName()); + httpLogger.setUseParentHandlers(false); + httpLogger.addHandler(new ConsoleHandler() { + @Override + public void publish(LogRecord record) { + String message = record.getMessage(); + if (record.getThrown() != null) message += ": " + record.getThrown().toString(); + HttpClient.this.logger.log(record.getLevel(), message); + } + }); + } + + private HttpRequestFactory createRequestFactory() { + return new NetHttpTransport().createRequestFactory(request -> { + request.setConnectTimeout(HttpClient.this.config.getConnectTimeout()); + request.setReadTimeout(HttpClient.this.config.getReadTimeout()); + request.setNumberOfRetries(HttpClient.this.config.getRetries()); + request.setIOExceptionHandler(new HttpBackOffIOExceptionHandler(new ExponentialBackOff.Builder().build())); + }); + } + + public String getBaseUrl() { + return this.config.getBaseUrl(); + } + + public ListenableFuture get(String path, HttpOption... options) { + return get(path, (TypeToken) null, options); + } + + public ListenableFuture get(String path, @Nullable Class returnType, HttpOption...options) { + return request(HttpMethods.GET, path, null, returnType, options); + } + + public ListenableFuture get(String path, @Nullable Type returnType, HttpOption...options) { + return request(HttpMethods.GET, path, null, returnType, options); + } + + public ListenableFuture get(String path, @Nullable TypeToken returnType, HttpOption...options) { + return request(HttpMethods.GET, path, null, returnType, options); + } + + public ListenableFuture post(String path, @Nullable Object content, HttpOption...options) { + return post(path, content, (TypeToken) null, options); + } + + public ListenableFuture post(String path, @Nullable Object content, @Nullable Class returnType, HttpOption...options) { + return request(HttpMethods.POST, path, content, returnType, options); + } + + public ListenableFuture post(String path, @Nullable Object content, @Nullable Type returnType, HttpOption...options) { + return request(HttpMethods.POST, path, content, returnType, options); + } + + public ListenableFuture post(String path, @Nullable Object content, @Nullable TypeToken returnType, HttpOption...options) { + return request(HttpMethods.POST, path, content, returnType, options); + } + + public ListenableFuture put(String path, @Nullable Object content, HttpOption...options) { + return put(path, content, (TypeToken) null, options); + } + + public ListenableFuture put(String path, @Nullable Object content, @Nullable Class returnType, HttpOption...options) { + return request(HttpMethods.PUT, path, content, returnType, options); + } + + public ListenableFuture put(String path, @Nullable Object content, @Nullable Type returnType, HttpOption...options) { + return request(HttpMethods.PUT, path, content, returnType, options); + } + + public ListenableFuture put(String path, @Nullable Object content, @Nullable TypeToken returnType, HttpOption...options) { + return request(HttpMethods.PUT, path, content, returnType, options); + } + + protected ListenableFuture request(String method, String path, @Nullable Object content, @Nullable Class returnType, HttpOption...options) { + return request(method, path, content, returnType == null ? null : TypeToken.of(returnType), options); + } + + protected ListenableFuture request(String method, String path, @Nullable Object content, @Nullable Type returnType, HttpOption...options) { + return request(method, path, content, returnType == null ? null : TypeToken.of(returnType), options); + } + + protected ListenableFuture request(String method, String path, @Nullable Object content, @Nullable TypeToken returnType, HttpOption...options) { + // NOTE: Serialization must happen synchronously, because getter methods may not be thread-safe + final HttpContent httpContent = content == null ? null : new Content(gson.toJson(content)); + + GenericUrl url = new GenericUrl(this.getBaseUrl() + path); + HttpRequest request; + try { + request = requestFactory.buildRequest(method, url, httpContent).setThrowExceptionOnExecuteError(false); + } catch (IOException e) { + this.getLogger().log(Level.SEVERE, "Failed to build request to " + url.toString(), e); + return Futures.immediateFailedFuture(e); + } + + request.getHeaders().set("X-OCN-Version", String.valueOf(ApiConstants.PROTOCOL_VERSION)); + request.getHeaders().setAccept("application/json"); + + for(HttpOption option : options) { + switch(option) { + case INFINITE_RETRY: + request.setNumberOfRetries(Integer.MAX_VALUE); + break; + + case NO_RETRY: + request.setNumberOfRetries(0); + break; + } + } + + return this.executorService.submit(new RequestCallable(request, returnType, options)); + } + + @Override + public void connect() throws IOException { + // Nothing to do + } + + @Override + public void disconnect() throws IOException { + ExecutorUtils.shutdownImpatiently(executorService, logger, SHUTDOWN_TIMEOUT); + } + + protected Logger getLogger() { + return logger; + } + + private class Content extends AbstractHttpContent { + final String json; + + protected Content(String json) { + super("application/json"); + this.json = json; + } + + @Override + public void writeTo(OutputStream out) throws IOException { + out.write(json.getBytes(Charsets.UTF_8)); + } + } + + private class RequestCallable implements Callable { + private final HttpRequest request; + private final @Nullable Content content; + private final TypeToken returnType; + private StackTraceElement[] callSite; + + public RequestCallable(HttpRequest request, @Nullable TypeToken returnType, HttpOption...options) { + this.request = checkNotNull(request, "http request"); + this.content = (Content) request.getContent(); + this.returnType = returnType; + this.callSite = new Exception().getStackTrace(); + } + + @Override + public T call() throws Exception { + try { + if(logger.isLoggable(Level.FINE)) { + logger.fine("Request " + request.getRequestMethod() + " " + request.getUrl() + + (content == null ? " (no content)" : "\n" + content.json)); + } + + final HttpResponse response = this.request.execute(); + final String json = response.parseAsString(); + + if(logger.isLoggable(Level.FINE)) { + logger.fine("Response " + response.getStatusCode() + + " " + response.getStatusMessage() + + "\n" + jsonUtils.prettify(json)); + } + + if(response.getStatusCode() < 300) { + // We need a 200 in order to return a document + if(returnType == null) return null; + try { + return gson.fromJson(json, returnType.getType()); + } catch (JsonParseException e) { + throw new ApiException("Parsing of " + returnType + " failed at [" + jsonUtils.errorContext(json, returnType.getType()) + "]", e, callSite); + } + } else if(response.getStatusCode() < 400 && returnType == null) { + // 3XX is a successful response only if no response document is expected + return null; + } else { + Reply reply; + // Error response might be a Reply, even if that is not the return type + try { + reply = gson.fromJson(json, Reply.class); + } catch(JsonParseException ex) { + reply = null; + } + + final String message; + if(reply != null && reply.error() != null) { + // If we have a Reply somehow, use the included error message + message = reply.error(); + } else { + // Otherwise, make a generic message + message = "HTTP " + response.getStatusCode() + " " + response.getStatusMessage(); + } + + final ApiException apiException; + switch(response.getStatusCode()) { + case 403: apiException = new Forbidden(message, reply); break; + case 404: apiException = new NotFound(message, reply); break; + case 409: apiException = new Conflict(message, reply); break; + case 422: apiException = new UnprocessableEntity(message, reply); break; + default: apiException = new ApiException(message, reply); break; + } + + throw apiException; + } + } catch(ApiException e) { + e.setCallSite(callSite); + throw e; + } catch (Throwable e) { + final String message = "Unhandled exception submitting request to " + this.request.getUrl(); + logger.log(Level.SEVERE, message, e); + throw new ApiException(message, e, callSite); + } + } + } +} diff --git a/API/api/src/main/java/tc/oc/api/http/HttpClientConfiguration.java b/API/api/src/main/java/tc/oc/api/http/HttpClientConfiguration.java new file mode 100644 index 0000000..e784dd8 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/http/HttpClientConfiguration.java @@ -0,0 +1,38 @@ +package tc.oc.api.http; + +public interface HttpClientConfiguration { + + int DEFAULT_THREADS = 0; + int DEFAULT_CONNECT_TIMEOUT = 20000; + int DEFAULT_READ_TIMEOUT = 20000; + int DEFAULT_RETRIES = 10; + + /** + * Base URL of the API. End points will be appended to this address. + */ + String getBaseUrl(); + + /** + * Number of threads to execute requests. 0 indicates an unbounded number + * of threads. + */ + int getThreads(); + + /** + * Timeout in milliseconds to establish a connection or 0 for an infinite + * timeout. + */ + int getConnectTimeout(); + + /** + * Timeout in milliseconds to read data from an established connection or 0 + * for an infinite timeout. + */ + int getReadTimeout(); + + /** + * Number of retries to execute a request until giving up. 0 indicates no + * retrying. + */ + int getRetries(); +} diff --git a/API/api/src/main/java/tc/oc/api/http/HttpClientConfigurationImpl.java b/API/api/src/main/java/tc/oc/api/http/HttpClientConfigurationImpl.java new file mode 100644 index 0000000..3ce171a --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/http/HttpClientConfigurationImpl.java @@ -0,0 +1,50 @@ +package tc.oc.api.http; + +import javax.inject.Inject; + +import tc.oc.minecraft.api.configuration.Configuration; +import tc.oc.minecraft.api.configuration.ConfigurationSection; + +import static com.google.common.base.Preconditions.checkNotNull; + +public class HttpClientConfigurationImpl implements HttpClientConfiguration { + + public static final String SECTION = "api.http"; + + public static final String RETRIES_PATH = "retries"; + public static final String READ_TIMEOUT_PATH = "read-timeout"; + public static final String CONNECT_TIMEOUT_PATH = "connect-timeout"; + public static final String THREADS_PATH = "threads"; + public static final String BASE_URL_PATH = "base-url"; + + private final ConfigurationSection config; + + @Inject public HttpClientConfigurationImpl(Configuration config) { + this.config = checkNotNull(config.getSection(SECTION)); + } + + @Override + public String getBaseUrl() { + return config.getString(BASE_URL_PATH); + } + + @Override + public int getThreads() { + return config.getInt(THREADS_PATH); + } + + @Override + public int getConnectTimeout() { + return config.getInt(CONNECT_TIMEOUT_PATH); + } + + @Override + public int getReadTimeout() { + return config.getInt(READ_TIMEOUT_PATH); + } + + @Override + public int getRetries() { + return config.getInt(RETRIES_PATH); + } +} diff --git a/API/api/src/main/java/tc/oc/api/http/HttpManifest.java b/API/api/src/main/java/tc/oc/api/http/HttpManifest.java new file mode 100644 index 0000000..f14b1db --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/http/HttpManifest.java @@ -0,0 +1,14 @@ +package tc.oc.api.http; + +import tc.oc.commons.core.inject.HybridManifest; + +public class HttpManifest extends HybridManifest { + + @Override + protected void configure() { + expose(HttpClient.class); + bind(HttpClient.class); + bind(HttpClientConfiguration.class) + .to(HttpClientConfigurationImpl.class); + } +} diff --git a/API/api/src/main/java/tc/oc/api/http/HttpOption.java b/API/api/src/main/java/tc/oc/api/http/HttpOption.java new file mode 100644 index 0000000..a631211 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/http/HttpOption.java @@ -0,0 +1,12 @@ +package tc.oc.api.http; + +/** + * Flags passed to HttpClient for individual requests. + * + * TODO nice-to-have: replace HttpClientConfig with this mechanism so + * any setting can be overridden per request. + */ +public enum HttpOption { + NO_RETRY, // Do not retry the request + INFINITE_RETRY // Retry the request forever +} diff --git a/API/api/src/main/java/tc/oc/api/http/QueryUri.java b/API/api/src/main/java/tc/oc/api/http/QueryUri.java new file mode 100644 index 0000000..1ba271a --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/http/QueryUri.java @@ -0,0 +1,40 @@ +package tc.oc.api.http; + +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.util.ArrayList; +import java.util.List; + +import com.google.api.client.repackaged.com.google.common.base.Joiner; +import com.google.common.base.Charsets; + +public class QueryUri { + + private static final Joiner JOINER = Joiner.on('&'); + + private final String prefix; + private final List vars = new ArrayList<>(); + + public QueryUri(String prefix) { + this.prefix = prefix; + } + + public QueryUri put(String name, Object value) { + try { + vars.add(URLEncoder.encode(name, Charsets.UTF_8.name()) + + "=" + + URLEncoder.encode(String.valueOf(value), Charsets.UTF_8.name())); + } catch(UnsupportedEncodingException e) { + throw new IllegalStateException(e); + } + return this; + } + + public String encode() { + if(vars.isEmpty()) { + return prefix; + } else { + return prefix + '?' + JOINER.join(vars); + } + } +} diff --git a/API/api/src/main/java/tc/oc/api/maps/MapModelManifest.java b/API/api/src/main/java/tc/oc/api/maps/MapModelManifest.java new file mode 100644 index 0000000..125c63f --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/maps/MapModelManifest.java @@ -0,0 +1,18 @@ +package tc.oc.api.maps; + +import com.google.inject.multibindings.OptionalBinder; +import tc.oc.api.docs.virtual.MapDoc; +import tc.oc.api.model.ModelBinders; +import tc.oc.commons.core.inject.HybridManifest; + +public class MapModelManifest extends HybridManifest implements ModelBinders { + + @Override + protected void configure() { + bindModel(MapDoc.class, model -> { + model.bindService().to(MapService.class); + }); + + OptionalBinder.newOptionalBinder(publicBinder(), MapService.class); + } +} diff --git a/API/api/src/main/java/tc/oc/api/maps/MapRatingsRequest.java b/API/api/src/main/java/tc/oc/api/maps/MapRatingsRequest.java new file mode 100644 index 0000000..58bc72c --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/maps/MapRatingsRequest.java @@ -0,0 +1,25 @@ +package tc.oc.api.maps; + +import tc.oc.api.docs.UserId; +import tc.oc.api.docs.virtual.MapDoc; + +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +public class MapRatingsRequest { + public final @Nullable String map_id; + public final String map_name; + public final String map_version; // TODO: use SemanticVersion class + public final List player_ids; + + public MapRatingsRequest(MapDoc map, Collection userIds) { + this.map_id = map._id(); + this.map_name = map.name(); + this.map_version = map.version().toString(); + + this.player_ids = new ArrayList<>(userIds.size()); + for(UserId userId : userIds) this.player_ids.add(userId.player_id()); + } +} diff --git a/API/api/src/main/java/tc/oc/api/maps/MapRatingsResponse.java b/API/api/src/main/java/tc/oc/api/maps/MapRatingsResponse.java new file mode 100644 index 0000000..6a22ab1 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/maps/MapRatingsResponse.java @@ -0,0 +1,12 @@ +package tc.oc.api.maps; + +import java.util.Map; + +import tc.oc.api.annotations.Serialize; +import tc.oc.api.docs.UserId; +import tc.oc.api.docs.virtual.Document; + +@Serialize +public interface MapRatingsResponse extends Document { + Map player_ratings(); +} diff --git a/API/api/src/main/java/tc/oc/api/maps/MapService.java b/API/api/src/main/java/tc/oc/api/maps/MapService.java new file mode 100644 index 0000000..cb34e25 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/maps/MapService.java @@ -0,0 +1,17 @@ +package tc.oc.api.maps; + +import java.util.Collection; + +import com.google.common.util.concurrent.ListenableFuture; +import tc.oc.api.docs.MapRating; +import tc.oc.api.docs.virtual.MapDoc; +import tc.oc.api.model.ModelService; + +public interface MapService extends ModelService { + + ListenableFuture rate(MapRating rating); + + ListenableFuture getRatings(MapRatingsRequest request); + + ListenableFuture updateMapsAndLookupAuthors(Collection maps); +} diff --git a/API/api/src/main/java/tc/oc/api/maps/MapUpdateMultiResponse.java b/API/api/src/main/java/tc/oc/api/maps/MapUpdateMultiResponse.java new file mode 100644 index 0000000..19d660b --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/maps/MapUpdateMultiResponse.java @@ -0,0 +1,26 @@ +package tc.oc.api.maps; + +import java.util.Collections; +import java.util.Map; +import java.util.UUID; +import javax.annotation.Nonnull; + +import tc.oc.api.annotations.Serialize; +import tc.oc.api.docs.virtual.UserDoc; +import tc.oc.api.message.types.UpdateMultiResponse; + +import static com.google.common.base.Preconditions.checkNotNull; + +@Serialize +public class MapUpdateMultiResponse extends UpdateMultiResponse { + + public @Nonnull Map users_by_uuid; + + /** Used by serializer */ + protected MapUpdateMultiResponse() {} + + public MapUpdateMultiResponse(Map users_by_uuid) { + super(0, 0, 0, 0, Collections.emptyMap()); + this.users_by_uuid = checkNotNull(users_by_uuid); + } +} diff --git a/API/api/src/main/java/tc/oc/api/maps/NullMapService.java b/API/api/src/main/java/tc/oc/api/maps/NullMapService.java new file mode 100644 index 0000000..4f383bc --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/maps/NullMapService.java @@ -0,0 +1,28 @@ +package tc.oc.api.maps; + +import java.util.Collection; +import java.util.Collections; + +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import tc.oc.api.docs.MapRating; +import tc.oc.api.docs.virtual.MapDoc; +import tc.oc.api.model.NullModelService; + +public class NullMapService extends NullModelService implements MapService { + + @Override + public ListenableFuture rate(MapRating rating) { + return Futures.immediateFuture(null); + } + + @Override + public ListenableFuture getRatings(MapRatingsRequest request) { + return Futures.immediateFuture(Collections::emptyMap); + } + + @Override + public ListenableFuture updateMapsAndLookupAuthors(Collection maps) { + return Futures.immediateFuture(new MapUpdateMultiResponse(Collections.emptyMap())); + } +} diff --git a/API/api/src/main/java/tc/oc/api/match/DeathSearchRequest.java b/API/api/src/main/java/tc/oc/api/match/DeathSearchRequest.java new file mode 100644 index 0000000..8bf4cab --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/match/DeathSearchRequest.java @@ -0,0 +1,26 @@ +package tc.oc.api.match; + +import java.time.Instant; +import tc.oc.api.annotations.Serialize; +import tc.oc.api.docs.Death; +import tc.oc.api.docs.PlayerId; +import tc.oc.api.message.types.FindRequest; + +import javax.annotation.Nullable; + +@Serialize +public class DeathSearchRequest extends FindRequest { + + private final @Nullable String victim; + private final @Nullable String killer; + private final @Nullable Instant date; + private final @Nullable Integer limit; + + public DeathSearchRequest(@Nullable PlayerId victim, @Nullable PlayerId killer, @Nullable Instant date, @Nullable Integer limit) { + this.victim = victim != null ? victim.player_id() : null; + this.killer = killer != null ? killer.player_id() : null; + this.date = date; + this.limit = limit; + } + +} diff --git a/API/api/src/main/java/tc/oc/api/match/MatchModelManifest.java b/API/api/src/main/java/tc/oc/api/match/MatchModelManifest.java new file mode 100644 index 0000000..4c74c64 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/match/MatchModelManifest.java @@ -0,0 +1,31 @@ +package tc.oc.api.match; + +import tc.oc.api.docs.Death; +import tc.oc.api.docs.Objective; +import tc.oc.api.docs.Participation; +import tc.oc.api.docs.virtual.DeathDoc; +import tc.oc.api.docs.virtual.MatchDoc; +import tc.oc.api.model.ModelBinders; +import tc.oc.commons.core.inject.HybridManifest; + +public class MatchModelManifest extends HybridManifest implements ModelBinders { + + @Override + protected void configure() { + bindModel(MatchDoc.class, model -> { + model.bindDefaultService().to(model.nullService()); + }); + + bindModel(Participation.Complete.class, Participation.Partial.class, model -> { + model.bindDefaultService().to(model.nullService()); + }); + + bindModel(Death.class, DeathDoc.Partial.class, model -> { + model.bindDefaultService().to(model.nullService()); + }); + + bindModel(Objective.class, model -> { + model.bindDefaultService().to(model.nullService()); + }); + } +} diff --git a/API/api/src/main/java/tc/oc/api/message/Message.java b/API/api/src/main/java/tc/oc/api/message/Message.java new file mode 100644 index 0000000..930779d --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/message/Message.java @@ -0,0 +1,20 @@ +package tc.oc.api.message; + +import tc.oc.api.docs.virtual.Document; + +/** + * A {@link Document} representing a request or response through the API. + * + * Every message type must be explicitly registered through a {@link MessageBinder}. + * Registration associates a type name with a base type and an instantiable type. + * + * The base type is the common ancestor of all types that represent + * this message. Serialization uses the base type to figure out the + * type name for outgoing messages. + * + * The instantiable type is the class that will represent incoming messages. + * This class must have a no-args constructor, and should include every field + * that any incoming message might have. + */ +public interface Message extends Document { +} diff --git a/API/api/src/main/java/tc/oc/api/message/MessageBinder.java b/API/api/src/main/java/tc/oc/api/message/MessageBinder.java new file mode 100644 index 0000000..eba6788 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/message/MessageBinder.java @@ -0,0 +1,27 @@ +package tc.oc.api.message; + +import com.google.inject.Binder; +import com.google.inject.Key; +import com.google.inject.binder.LinkedBindingBuilder; +import com.google.inject.multibindings.Multibinder; + +public class MessageBinder { + + private final Multibinder> messages; + + public MessageBinder(Binder binder) { + this.messages = Multibinder.newSetBinder(binder, new Key>(){}); + } + + public LinkedBindingBuilder> addBinding() { + return messages.addBinding(); + } + + public void register(Class type, String name) { + addBinding().toInstance(new MessageMeta<>(type, name)); + } + + public void register(Class type) { + register(type, type.getSimpleName()); + } +} diff --git a/API/api/src/main/java/tc/oc/api/message/MessageHandler.java b/API/api/src/main/java/tc/oc/api/message/MessageHandler.java new file mode 100644 index 0000000..78c58d2 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/message/MessageHandler.java @@ -0,0 +1,9 @@ +package tc.oc.api.message; + +import com.google.common.reflect.TypeToken; +import tc.oc.api.queue.Delivery; +import tc.oc.api.queue.Metadata; + +public interface MessageHandler { + void handleDelivery(T message, TypeToken type, Metadata properties, Delivery delivery); +} diff --git a/API/api/src/main/java/tc/oc/api/message/MessageListener.java b/API/api/src/main/java/tc/oc/api/message/MessageListener.java new file mode 100644 index 0000000..e9ebbe3 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/message/MessageListener.java @@ -0,0 +1,30 @@ +package tc.oc.api.message; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +import tc.oc.api.config.ApiConstants; +import tc.oc.api.queue.Delivery; +import tc.oc.api.queue.Queue; + +/** + * A {@link Queue} consumer that can handle multiple message types. + * Message handler methods are annotated with {@link MessageListener.HandleMessage}, + * and must take a {@link Message} subtype as their first parameter. + * They can optionally take a {@link Delivery} as the second parameter. + */ +public interface MessageListener { + + default boolean listenWhileSuspended() { + return false; + } + + @Retention(RetentionPolicy.RUNTIME) + @interface HandleMessage { + /** + * Ignore messages with a protocol_version header other than this. + * A version of -1 will accept all messages. + */ + int protocolVersion() default ApiConstants.PROTOCOL_VERSION; + } +} diff --git a/API/api/src/main/java/tc/oc/api/message/MessageMeta.java b/API/api/src/main/java/tc/oc/api/message/MessageMeta.java new file mode 100644 index 0000000..06e843a --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/message/MessageMeta.java @@ -0,0 +1,25 @@ +package tc.oc.api.message; + +public class MessageMeta { + + private final Class type; + private final String name; + + public MessageMeta(Class type, String name) { + this.name = name; + this.type = type; + } + + public Class type() { + return type; + } + + public String name() { + return name; + } + + @Override + public String toString() { + return name; + } +} diff --git a/API/api/src/main/java/tc/oc/api/message/MessageQueue.java b/API/api/src/main/java/tc/oc/api/message/MessageQueue.java new file mode 100644 index 0000000..ce96671 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/message/MessageQueue.java @@ -0,0 +1,38 @@ +package tc.oc.api.message; + +import java.util.concurrent.Executor; +import javax.annotation.Nullable; + +import com.google.common.reflect.TypeToken; + +public interface MessageQueue { + + /** + * Tell the queue to receive messages of the given type + */ + void bind(Class type); + + void subscribe(TypeToken messageType, MessageHandler handler, @Nullable Executor executor); + + default void subscribe(Class messageType, MessageHandler handler, @Nullable Executor executor) { + subscribe(TypeToken.of(messageType), handler, executor); + } + + default void subscribe(TypeToken messageType, MessageHandler handler) { + subscribe(messageType, handler, null); + } + + default void subscribe(Class messageType, MessageHandler handler) { + subscribe(messageType, handler, null); + } + + void subscribe(MessageListener listener, @Nullable Executor executor); + + default void subscribe(MessageListener listener) { + subscribe(listener, null); + } + + void unsubscribe(MessageHandler handler); + + void unsubscribe(MessageListener listener); +} diff --git a/API/api/src/main/java/tc/oc/api/message/MessageRegistry.java b/API/api/src/main/java/tc/oc/api/message/MessageRegistry.java new file mode 100644 index 0000000..e8b21ed --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/message/MessageRegistry.java @@ -0,0 +1,166 @@ +package tc.oc.api.message; + +import java.lang.reflect.Modifier; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.logging.Logger; +import javax.annotation.Nullable; +import javax.inject.Inject; +import javax.inject.Singleton; + +import com.google.common.base.Function; +import com.google.common.base.Joiner; +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; +import com.google.common.reflect.TypeToken; +import org.apache.commons.collections.map.HashedMap; +import tc.oc.api.docs.virtual.Model; +import tc.oc.api.document.DocumentRegistry; +import tc.oc.api.exceptions.SerializationException; +import tc.oc.api.message.types.ModelMessage; +import tc.oc.api.model.ModelRegistry; +import tc.oc.api.model.NoSuchModelException; +import tc.oc.commons.core.logging.Loggers; +import tc.oc.commons.core.reflect.InheritablePropertyVisitor; +import tc.oc.commons.core.reflect.Types; + +@Singleton +public class MessageRegistry { + + protected final Logger logger; + protected final DocumentRegistry documentRegistry; + protected final ModelRegistry modelRegistry; + private final Map> byName = new HashedMap(); + + private final LoadingCache, MessageMeta> byType = CacheBuilder.newBuilder().build( + new CacheLoader, MessageMeta>() { + @Override + public MessageMeta load(Class type) throws Exception { + // Registered types are explicitly inserted into the cache, + // so a cache miss means the type itself is not a registered + // message, and we need to look for an ancestor that is. + return findAncestorMeta(type); + } + } + ); + + @Inject MessageRegistry(Loggers loggers, Set> messages, DocumentRegistry documentRegistry, ModelRegistry modelRegistry) { + this.modelRegistry = modelRegistry; + this.logger = loggers.get(getClass()); + this.documentRegistry = documentRegistry; + + messages.forEach(meta -> { + if(ModelMessage.class.isAssignableFrom(meta.type())) { + if(meta.type().getTypeParameters().length > 1) { + throw new SerializationException(ModelMessage.class.getSimpleName() + " subtype must have no more than one type parameter"); + } + } + + if(byName.containsKey(meta.name())) { + throw new SerializationException("Tried to register multiple message types for name " + meta.name()); + } + + byName.put(meta.name(), meta); + byType.put(meta.type(), meta); + }); + } + + public TypeToken resolve(String name) { + return resolve(name, Optional.empty()); + } + + public TypeToken resolve(String name, Optional modelName) { + final MessageMeta meta = byName.get(name); + if(meta == null) { + throw new NoSuchMessageException("No registered message type named " + name); + } + + TypeToken token = TypeToken.of(meta.type()); + if(ModelMessage.class.isAssignableFrom(meta.type()) && modelName.isPresent()) { + try { + token = modelMessageType(token, modelRegistry.resolve(modelName.get()).completeType()); + } catch(NoSuchModelException e) { + throw new NoSuchMessageException(e.getMessage()); + } + } + + return token; + } + + private static > TypeToken modelMessageType(TypeToken messageType, TypeToken modelType) { + if(messageType.getRawType().getTypeParameters().length == 0) { + return messageType; + } else { + return (TypeToken) TypeToken.of(new ParameterizedType() { + @Override + public Type[] getActualTypeArguments() { + return new Type[]{ modelType.getType() }; + } + + @Override + public Type getRawType() { + return messageType.getRawType(); + } + + @Override + public Type getOwnerType() { + return messageType.getRawType().getEnclosingClass(); + } + }); + } + } + + public String typeName(Class type) { + return getMeta(type).name(); + } + + private MessageMeta getMeta(Class type) { + return byType.getUnchecked(type); + } + + private MessageMeta findAncestorMeta(final Class type) { + // We don't want to trigger any cache loads, we just want to search what is + // already in the cache, which is exactly what this map view does. + final Map, MessageMeta> byTypeMap = byType.asMap(); + + Map, MessageMeta> metas = Types.walkAncestors( + type, + Types.assignableTo(Message.class), + new InheritablePropertyVisitor<>(new Function, MessageMeta>() { + @Override + public @Nullable MessageMeta apply(Class cls) { + if(type == cls) return null; + return byTypeMap.get(cls); + } + }) + ).values(); + + switch(metas.size()) { + case 0: throw new SerializationException("No name found for message type " + type.getName()); + case 1: return metas.values().iterator().next(); + default: throw new SerializationException("Ambiguous name for message type " + type.getName() + ": could be any of " + Joiner.on(", ").join(metas.values())); + } + } + + private boolean isInstantiable(Class type) { + if( + type.isAnonymousClass() || + type.isLocalClass() || + type.isMemberClass() || + type.isSynthetic() || + Modifier.isAbstract(type.getModifiers()) + ) return false; + + try { + type.getDeclaredConstructor().setAccessible(true); + } catch(NoSuchMethodException e) { + return false; + } + + return true; + } +} diff --git a/API/api/src/main/java/tc/oc/api/message/MessagesManifest.java b/API/api/src/main/java/tc/oc/api/message/MessagesManifest.java new file mode 100644 index 0000000..ce1b6d8 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/message/MessagesManifest.java @@ -0,0 +1,51 @@ +package tc.oc.api.message; + +import tc.oc.api.engagement.EngagementUpdateRequest; +import tc.oc.api.message.types.CycleRequest; +import tc.oc.api.message.types.CycleResponse; +import tc.oc.api.message.types.FindMultiRequest; +import tc.oc.api.message.types.FindMultiResponse; +import tc.oc.api.message.types.FindRequest; +import tc.oc.api.message.types.ModelDelete; +import tc.oc.api.message.types.ModelUpdate; +import tc.oc.api.message.types.Ping; +import tc.oc.api.message.types.PlayGameRequest; +import tc.oc.api.message.types.PlayerTeleportRequest; +import tc.oc.api.message.types.Reply; +import tc.oc.api.message.types.UpdateMultiResponse; +import tc.oc.api.servers.ServerSearchRequest; +import tc.oc.api.sessions.BadNickname; +import tc.oc.api.sessions.SessionChange; +import tc.oc.commons.core.inject.HybridManifest; + +public class MessagesManifest extends HybridManifest { + @Override + public void configure() { + bindAndExpose(MessageRegistry.class); + + publicBinder().forOptional(MessageQueue.class) + .setDefault().to(NullMessageQueue.class); + + final MessageBinder messages = new MessageBinder(publicBinder()); + + messages.register(Reply.class); + messages.register(BadNickname.class); + messages.register(Ping.class); + + messages.register(FindRequest.class); + messages.register(FindMultiRequest.class); + messages.register(FindMultiResponse.class); + + messages.register(ModelUpdate.class); + messages.register(ModelDelete.class); + messages.register(UpdateMultiResponse.class); + + messages.register(ServerSearchRequest.class); + messages.register(EngagementUpdateRequest.class); + messages.register(PlayerTeleportRequest.class); + messages.register(SessionChange.class); + messages.register(PlayGameRequest.class); + messages.register(CycleRequest.class); + messages.register(CycleResponse.class); + } +} diff --git a/API/api/src/main/java/tc/oc/api/message/NoSuchMessageException.java b/API/api/src/main/java/tc/oc/api/message/NoSuchMessageException.java new file mode 100644 index 0000000..3806894 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/message/NoSuchMessageException.java @@ -0,0 +1,9 @@ +package tc.oc.api.message; + +import tc.oc.api.exceptions.SerializationException; + +public class NoSuchMessageException extends SerializationException { + public NoSuchMessageException(String message) { + super(message); + } +} diff --git a/API/api/src/main/java/tc/oc/api/message/NullMessageQueue.java b/API/api/src/main/java/tc/oc/api/message/NullMessageQueue.java new file mode 100644 index 0000000..f500b94 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/message/NullMessageQueue.java @@ -0,0 +1,24 @@ +package tc.oc.api.message; + +import java.util.concurrent.Executor; +import javax.annotation.Nullable; + +import com.google.common.reflect.TypeToken; + +public class NullMessageQueue implements MessageQueue { + + @Override + public void bind(Class type) {} + + @Override + public void subscribe(TypeToken messageType, MessageHandler handler, @Nullable Executor executor) {} + + @Override + public void subscribe(MessageListener listener, @Nullable Executor executor) {} + + @Override + public void unsubscribe(MessageHandler handler) {} + + @Override + public void unsubscribe(MessageListener listener) {} +} diff --git a/API/api/src/main/java/tc/oc/api/message/types/CycleRequest.java b/API/api/src/main/java/tc/oc/api/message/types/CycleRequest.java new file mode 100644 index 0000000..45aa063 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/message/types/CycleRequest.java @@ -0,0 +1,16 @@ +package tc.oc.api.message.types; + +import tc.oc.api.annotations.Serialize; +import tc.oc.api.message.Message; +import tc.oc.api.queue.MessageDefaults; + +@Serialize +@MessageDefaults.RoutingKey("match_maker") +@MessageDefaults.Persistent(false) +@MessageDefaults.ExpirationMillis(10000) +public interface CycleRequest extends Message { + String server_id(); + String map_id(); + int min_players(); + int max_players(); +} diff --git a/API/api/src/main/java/tc/oc/api/message/types/CycleResponse.java b/API/api/src/main/java/tc/oc/api/message/types/CycleResponse.java new file mode 100644 index 0000000..b2d9374 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/message/types/CycleResponse.java @@ -0,0 +1,33 @@ +package tc.oc.api.message.types; + +import java.util.Collections; +import java.util.Map; +import java.util.UUID; +import javax.annotation.Nullable; + +import tc.oc.api.annotations.Serialize; + +@Serialize +public interface CycleResponse extends Reply { + + // Player UUID -> server_id + // null server_id is lobby + Map destinations(); + + CycleResponse EMPTY = new CycleResponse() { + @Override + public Map destinations() { + return Collections.emptyMap(); + } + + @Override + public boolean success() { + return true; + } + + @Override + public @Nullable String error() { + return null; + } + }; +} diff --git a/API/api/src/main/java/tc/oc/api/message/types/FindMultiRequest.java b/API/api/src/main/java/tc/oc/api/message/types/FindMultiRequest.java new file mode 100644 index 0000000..6d28d53 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/message/types/FindMultiRequest.java @@ -0,0 +1,22 @@ +package tc.oc.api.message.types; + +import java.util.Collection; + +import com.google.common.reflect.TypeToken; +import tc.oc.api.annotations.Serialize; +import tc.oc.api.docs.virtual.PartialModel; + +public class FindMultiRequest extends FindRequest { + + private final Collection ids; + @Serialize public Collection ids() { return ids; } + + public FindMultiRequest(TypeToken model, Collection ids) { + super(model); + this.ids = ids; + } + + public FindMultiRequest(Class model, Collection ids) { + this(TypeToken.of(model), ids); + } +} diff --git a/API/api/src/main/java/tc/oc/api/message/types/FindMultiResponse.java b/API/api/src/main/java/tc/oc/api/message/types/FindMultiResponse.java new file mode 100644 index 0000000..4f33a1e --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/message/types/FindMultiResponse.java @@ -0,0 +1,12 @@ +package tc.oc.api.message.types; + +import java.util.List; +import javax.annotation.Nonnull; + +import tc.oc.api.annotations.Serialize; +import tc.oc.api.docs.virtual.PartialModel; + +@Serialize +public interface FindMultiResponse extends ModelMessage { + @Nonnull List documents(); +} diff --git a/API/api/src/main/java/tc/oc/api/message/types/FindRequest.java b/API/api/src/main/java/tc/oc/api/message/types/FindRequest.java new file mode 100644 index 0000000..dfd2dc2 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/message/types/FindRequest.java @@ -0,0 +1,38 @@ +package tc.oc.api.message.types; + +import javax.annotation.Nullable; + +import com.google.common.reflect.TypeToken; +import tc.oc.api.annotations.Serialize; +import tc.oc.api.docs.virtual.PartialModel; +import tc.oc.commons.core.reflect.Types; + +public class FindRequest implements ModelMessage { + + private final TypeToken model; + + public FindRequest(@Nullable TypeToken model) { + this.model = model != null ? model : Types.assertFullySpecified(new TypeToken(getClass()){}); + } + + public FindRequest(@Nullable Class model) { + this(model == null ? null : TypeToken.of(model)); + } + + protected FindRequest() { + this((Class) null); + } + + @Serialize public @Nullable Integer skip() { + return null; + } + + @Serialize public @Nullable Integer limit() { + return null; + } + + @Override + public TypeToken model() { + return model; + } +} diff --git a/API/api/src/main/java/tc/oc/api/message/types/ModelDelete.java b/API/api/src/main/java/tc/oc/api/message/types/ModelDelete.java new file mode 100644 index 0000000..d100a0c --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/message/types/ModelDelete.java @@ -0,0 +1,9 @@ +package tc.oc.api.message.types; + +import tc.oc.api.annotations.Serialize; +import tc.oc.api.docs.virtual.Model; + +@Serialize +public interface ModelDelete extends ModelMessage { + String document_id(); +} diff --git a/API/api/src/main/java/tc/oc/api/message/types/ModelMessage.java b/API/api/src/main/java/tc/oc/api/message/types/ModelMessage.java new file mode 100644 index 0000000..caf037b --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/message/types/ModelMessage.java @@ -0,0 +1,13 @@ +package tc.oc.api.message.types; + +import com.google.common.reflect.TypeToken; +import tc.oc.api.docs.virtual.PartialModel; +import tc.oc.api.message.Message; +import tc.oc.api.queue.MessageDefaults; + +@MessageDefaults.RoutingKey("api_request") +public interface ModelMessage extends Message { + default TypeToken model() { + return new TypeToken(getClass()){}; + } +} diff --git a/API/api/src/main/java/tc/oc/api/message/types/ModelUpdate.java b/API/api/src/main/java/tc/oc/api/message/types/ModelUpdate.java new file mode 100644 index 0000000..19a3fdc --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/message/types/ModelUpdate.java @@ -0,0 +1,8 @@ +package tc.oc.api.message.types; + +import tc.oc.api.annotations.Serialize; +import tc.oc.api.docs.virtual.Model; + +@Serialize +public interface ModelUpdate extends PartialModelUpdate { +} diff --git a/API/api/src/main/java/tc/oc/api/message/types/PartialModelUpdate.java b/API/api/src/main/java/tc/oc/api/message/types/PartialModelUpdate.java new file mode 100644 index 0000000..7c353d4 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/message/types/PartialModelUpdate.java @@ -0,0 +1,11 @@ +package tc.oc.api.message.types; + +import javax.annotation.Nonnull; + +import tc.oc.api.annotations.Serialize; +import tc.oc.api.docs.virtual.PartialModel; + +@Serialize +public interface PartialModelUpdate extends ModelMessage { + @Nonnull T document(); +} diff --git a/API/api/src/main/java/tc/oc/api/message/types/Ping.java b/API/api/src/main/java/tc/oc/api/message/types/Ping.java new file mode 100644 index 0000000..27774eb --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/message/types/Ping.java @@ -0,0 +1,30 @@ +package tc.oc.api.message.types; + +import tc.oc.api.annotations.Serialize; +import tc.oc.api.message.Message; +import tc.oc.api.queue.MessageDefaults; + +/** + * Generic request used for testing. The {@link #reply_with} property + * tells the API what to do in response: + * + * success: Do nothing, which should result in an automatic successful {@link Reply} + * failure: Exclicitly send back a failed {@link Reply} + * exception: Raise an exception, which should generate a failed {@link Reply} + */ +@Serialize +@MessageDefaults.ExpirationMillis(30000) +@MessageDefaults.Persistent(false) +public class Ping implements Message { + public enum ReplyWith { success, failure, exception } + + public final ReplyWith reply_with; + + public Ping(ReplyWith reply_with) { + this.reply_with = reply_with; + } + + public Ping() { + this(ReplyWith.success); + } +} diff --git a/API/api/src/main/java/tc/oc/api/message/types/PlayGameRequest.java b/API/api/src/main/java/tc/oc/api/message/types/PlayGameRequest.java new file mode 100644 index 0000000..d4796aa --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/message/types/PlayGameRequest.java @@ -0,0 +1,17 @@ +package tc.oc.api.message.types; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import tc.oc.api.annotations.Serialize; +import tc.oc.api.message.Message; +import tc.oc.api.queue.MessageDefaults; + +@Serialize +@MessageDefaults.RoutingKey("match_maker") +@MessageDefaults.Persistent(false) +@MessageDefaults.ExpirationMillis(10000) +public interface PlayGameRequest extends Message { + @Nonnull String user_id(); + @Nullable String arena_id(); +} diff --git a/API/api/src/main/java/tc/oc/api/message/types/PlayerTeleportRequest.java b/API/api/src/main/java/tc/oc/api/message/types/PlayerTeleportRequest.java new file mode 100644 index 0000000..53d8460 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/message/types/PlayerTeleportRequest.java @@ -0,0 +1,29 @@ +package tc.oc.api.message.types; + +import java.util.UUID; +import javax.annotation.Nullable; + +import tc.oc.api.annotations.Serialize; +import tc.oc.api.docs.virtual.ServerDoc; +import tc.oc.api.message.Message; +import tc.oc.api.queue.MessageDefaults; + +@MessageDefaults.ExpirationMillis(10000) +@MessageDefaults.RoutingKey("teleport") +public class PlayerTeleportRequest implements Message { + @Serialize public UUID player_uuid; + @Serialize public @Nullable UUID target_player_uuid; + + private @Nullable ServerDoc.Identity target_server; + + @Serialize public void target_server(@Nullable ServerDoc.Identity server) { target_server = server; } + @Serialize public @Nullable ServerDoc.Identity target_server() { return target_server; } + + public PlayerTeleportRequest() {} + + public PlayerTeleportRequest(UUID player_uuid, ServerDoc.Identity target_server, @Nullable UUID target_player_uuid) { + this.player_uuid = player_uuid; + this.target_server = target_server; + this.target_player_uuid = target_player_uuid; + } +} diff --git a/API/api/src/main/java/tc/oc/api/message/types/Reply.java b/API/api/src/main/java/tc/oc/api/message/types/Reply.java new file mode 100644 index 0000000..3d61170 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/message/types/Reply.java @@ -0,0 +1,37 @@ +package tc.oc.api.message.types; + +import javax.annotation.Nullable; + +import tc.oc.api.annotations.Serialize; +import tc.oc.api.message.Message; + +/** + * Generic reply with a success flag and optional error message. + * This can used on its own, or subclassed to add more info. + * + * The API should always send back some subtype of this in response + * to any message with the reply-to set in the AMQP metadata. Use a + * {@link tc.oc.api.queue.Transaction} to send such a message and + * listen for the reply. + * + * If the queue consumer does not explicitly reply, a generic success + * reply will be generated. If the consumer raises an exception while + * handling the message, a failed reply will be generated. + * + * + */ +@Serialize +public interface Reply extends Message { + boolean success(); + @Nullable String error(); + + Reply SUCCESS = new Reply() { + @Override public boolean success() { return true; } + @Override public @Nullable String error() { return null; } + }; + + Reply FAILURE = new Reply() { + @Override public boolean success() { return false; } + @Override public String error() { return "Failure!"; } + }; +} diff --git a/API/api/src/main/java/tc/oc/api/message/types/ServerUpdateRequest.java b/API/api/src/main/java/tc/oc/api/message/types/ServerUpdateRequest.java new file mode 100644 index 0000000..e197d9a --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/message/types/ServerUpdateRequest.java @@ -0,0 +1,17 @@ +package tc.oc.api.message.types; + +import tc.oc.api.annotations.Serialize; +import tc.oc.api.docs.virtual.Document; +import tc.oc.api.docs.virtual.ServerDoc; + +/** + * Sent to the API to request changes to a server document + */ +public class ServerUpdateRequest implements Document { + + @Serialize public final ServerDoc.Partial server; + + public ServerUpdateRequest(ServerDoc.Partial server) { + this.server = server; + } +} diff --git a/API/api/src/main/java/tc/oc/api/message/types/UpdateMultiRequest.java b/API/api/src/main/java/tc/oc/api/message/types/UpdateMultiRequest.java new file mode 100644 index 0000000..ec820e7 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/message/types/UpdateMultiRequest.java @@ -0,0 +1,11 @@ +package tc.oc.api.message.types; + +import java.util.Collection; + +import tc.oc.api.annotations.Serialize; +import tc.oc.api.docs.virtual.Document; + +@Serialize +public interface UpdateMultiRequest extends Document { + Collection documents(); +} diff --git a/API/api/src/main/java/tc/oc/api/message/types/UpdateMultiResponse.java b/API/api/src/main/java/tc/oc/api/message/types/UpdateMultiResponse.java new file mode 100644 index 0000000..c71a273 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/message/types/UpdateMultiResponse.java @@ -0,0 +1,67 @@ +package tc.oc.api.message.types; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import javax.annotation.Nullable; + +import tc.oc.api.annotations.Serialize; + +import static com.google.common.base.Preconditions.checkNotNull; + +public class UpdateMultiResponse implements Reply { + @Serialize public int created; + @Serialize public int updated; + @Serialize public int skipped; + @Serialize public int failed; + + // _id -> property -> messages[] + @Serialize public Map>> errors; + + protected UpdateMultiResponse() {} + + protected UpdateMultiResponse(int created, int updated, int skipped, int failed, Map>> errors) { + this.created = created; + this.updated = updated; + this.skipped = skipped; + this.failed = failed; + this.errors = checkNotNull(errors); + } + + public static final UpdateMultiResponse EMPTY = new UpdateMultiResponse(0, 0, 0, 0, Collections.emptyMap()); + + @Override + public boolean success() { + return failed <= 0; + } + + @Override + public @Nullable String error() { + return success() ? null : formattedErrors(); + } + + private transient String formattedErrors; + public String formattedErrors() { + if(formattedErrors == null) { + StringBuilder text = new StringBuilder(failed + " documents failed:\n"); + for(Map.Entry>> document : errors.entrySet()) { + text.append(" ").append(document.getKey()).append(" :\n"); + for(Map.Entry> property : document.getValue().entrySet()) { + for(String problem : property.getValue()) { + text.append(" ").append(property.getKey()).append(" ").append(problem); + } + } + } + formattedErrors = text.toString(); + } + return formattedErrors; + } + + @Override + public String toString() { + return created + " created, " + + updated + " updated, " + + skipped + " skipped, " + + failed + " failed"; + } +} diff --git a/API/api/src/main/java/tc/oc/api/model/BatchUpdateRequest.java b/API/api/src/main/java/tc/oc/api/model/BatchUpdateRequest.java new file mode 100644 index 0000000..15456b7 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/model/BatchUpdateRequest.java @@ -0,0 +1,39 @@ +package tc.oc.api.model; + +import java.util.ArrayList; +import java.util.List; +import javax.inject.Inject; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import tc.oc.api.docs.virtual.Document; +import tc.oc.api.docs.virtual.PartialModel; +import tc.oc.api.message.types.UpdateMultiRequest; + +/** + * Collects {@link Document}s to be sent to the API as a {@link UpdateMultiRequest}. + * The documents are serialized immediately when added, which is useful if you want to + * send the update at some later time when the document cannot be safely serialized. + */ +public class BatchUpdateRequest implements UpdateMultiRequest { + + private final Gson gson; + private final List documents = new ArrayList<>(); + + @Inject BatchUpdateRequest(Gson gson) { + this.gson = gson; + } + + @Override + public List documents() { + return documents; + } + + public void add(T document) { + documents.add((JsonObject) gson.toJsonTree(document)); + } + + public void addAll(Iterable documents) { + documents.forEach(this::add); + } +} diff --git a/API/api/src/main/java/tc/oc/api/model/BatchUpdater.java b/API/api/src/main/java/tc/oc/api/model/BatchUpdater.java new file mode 100644 index 0000000..30d9d88 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/model/BatchUpdater.java @@ -0,0 +1,19 @@ +package tc.oc.api.model; + +import java.util.Collection; + +import tc.oc.api.docs.virtual.PartialModel; + +public interface BatchUpdater { + + void flush(); + + void schedule(); + + void update(T doc); + + default void updateMulti(Collection doc) { + doc.forEach(this::update); + } +} + diff --git a/API/api/src/main/java/tc/oc/api/model/BatchUpdaterFactory.java b/API/api/src/main/java/tc/oc/api/model/BatchUpdaterFactory.java new file mode 100644 index 0000000..ee4ccaf --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/model/BatchUpdaterFactory.java @@ -0,0 +1,66 @@ +package tc.oc.api.model; + +import javax.annotation.Nullable; +import javax.inject.Inject; +import javax.inject.Provider; + +import java.time.Duration; +import tc.oc.api.docs.virtual.PartialModel; +import tc.oc.api.message.types.UpdateMultiRequest; +import tc.oc.commons.core.scheduler.DebouncedTask; +import tc.oc.commons.core.scheduler.Scheduler; + +public class BatchUpdaterFactory { + + private final UpdateService service; + private final Provider updateProvider; + private final Scheduler scheduler; + + @Inject BatchUpdaterFactory(UpdateService service, Provider updateProvider, Scheduler scheduler) { + this.service = service; + this.updateProvider = updateProvider; + this.scheduler = scheduler; + } + + public BatchUpdater createBatchUpdater() { + return createBatchUpdater(Duration.ZERO); + } + + public BatchUpdater createBatchUpdater(Duration delay) { + return new BatchUpdaterImpl(delay); + } + + private class BatchUpdaterImpl implements BatchUpdater { + + final DebouncedTask task; + @Nullable BatchUpdateRequest batchUpdate; + + BatchUpdaterImpl(Duration delay) { + this.task = scheduler.createDebouncedTask(delay, this::flush); + } + + @Override + public void flush() { + if(batchUpdate != null) { + final BatchUpdateRequest batchUpdate = this.batchUpdate; + this.batchUpdate = null; + task.cancel(); + service.updateMulti((UpdateMultiRequest) batchUpdate); + } + } + + @Override + public void schedule() { + task.schedule(); + } + + @Override + public void update(T doc) { + if(batchUpdate == null) { + batchUpdate = updateProvider.get(); + } + batchUpdate.add(doc); + schedule(); + } + } +} diff --git a/API/api/src/main/java/tc/oc/api/model/HttpModelService.java b/API/api/src/main/java/tc/oc/api/model/HttpModelService.java new file mode 100644 index 0000000..404fdd4 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/model/HttpModelService.java @@ -0,0 +1,92 @@ +package tc.oc.api.model; + +import java.util.Collection; +import javax.inject.Inject; + +import com.google.common.reflect.TypeToken; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import tc.oc.api.docs.virtual.Model; +import tc.oc.api.docs.virtual.PartialModel; +import tc.oc.api.http.HttpOption; +import tc.oc.api.message.types.PartialModelUpdate; +import tc.oc.api.message.types.UpdateMultiRequest; +import tc.oc.api.message.types.UpdateMultiResponse; +import tc.oc.commons.core.concurrent.FutureUtils; + +import static com.google.common.base.Preconditions.checkArgument; + +/** + * Generic base class for services that provide CRUD operations on a particular model. + * @param Base type for all outgoing documents i.e. common ancestor to all of the model's interfaces + * @param Type of incoming documents, i.e. the "complete" model + */ +public class HttpModelService extends HttpQueryService implements ModelService { + + @Inject private ModelMeta meta; + + @Override + public TypeToken partialType() { + return meta.partialType(); + } + + protected String updateMultiUri() { + return collectionUri("update_multi"); + } + + protected String memberUri(Partial model) { + checkArgument(model instanceof Model); + return memberUri(((Model) model)._id()); + } + + protected String memberUri(Partial model, String action) { + checkArgument(model instanceof Model); + return memberUri(((Model) model)._id(), action); + } + + @Override + public ListenableFuture update(String id, PartialModelUpdate request) { + return handleUpdate(client().put(memberUri(id), request, meta.completeType(), HttpOption.INFINITE_RETRY)); + } + + @Override + public ListenableFuture update(String id, Partial partial) { + return update(id, updateRequest(partial)); + } + + @Override + public ListenableFuture update(Partial partial) { + if(!(partial instanceof Model)) { + throw new IllegalArgumentException("Partial model has no _id field"); + } + Model model = (Model) partial; + if(model._id() == null) { + throw new IllegalArgumentException("_id is null"); + } + return update(model._id(), partial); + } + + @Override + public ListenableFuture updateMulti(Collection models) { + return updateMulti((UpdateMultiRequest) () -> models); + } + + @Override + public ListenableFuture updateMulti(UpdateMultiRequest request) { + if(request.documents().isEmpty()) { + return Futures.immediateFuture(UpdateMultiResponse.EMPTY); + } else { + return client().post(updateMultiUri(), request, UpdateMultiResponse.class, HttpOption.INFINITE_RETRY); + } + } + + protected ListenableFuture updateMulti(Collection models, Class returnType) { + return client().post(updateMultiUri(), (UpdateMultiRequest) () -> models, returnType, HttpOption.INFINITE_RETRY); + } + + protected ListenableFuture handleUpdate(ListenableFuture future) { + return FutureUtils.peek(future, this::handleUpdate); + } + + protected void handleUpdate(Complete doc) {} +} diff --git a/API/api/src/main/java/tc/oc/api/model/HttpQueryService.java b/API/api/src/main/java/tc/oc/api/model/HttpQueryService.java new file mode 100644 index 0000000..db517f7 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/model/HttpQueryService.java @@ -0,0 +1,80 @@ +package tc.oc.api.model; + +import java.util.Collection; +import javax.inject.Inject; + +import com.damnhandy.uri.template.UriTemplate; +import com.google.common.reflect.TypeToken; +import com.google.common.util.concurrent.ListenableFuture; +import tc.oc.api.docs.virtual.Model; +import tc.oc.api.http.HttpClient; +import tc.oc.api.http.HttpOption; +import tc.oc.api.message.types.FindMultiRequest; +import tc.oc.api.message.types.FindMultiResponse; +import tc.oc.api.message.types.FindRequest; + +import static com.google.common.base.Preconditions.checkArgument; + +public class HttpQueryService implements QueryService { + + @Inject private ModelMeta meta; + @Inject private HttpClient client; + + @Override + public TypeToken completeType() { + return meta.completeType(); + } + + protected HttpClient client() { + return this.client; + } + protected String collectionUri() { + return '/' + meta.pluralName(); + } + + protected String collectionUri(String action) { + return UriTemplate.fromTemplate("/{model}/{action}") + .set("model", meta.pluralName()) + .set("action", action) + .expand(); + } + + protected String findMultiUri() { + return collectionUri("find_multi"); + } + + protected String memberUri(String id) { + return UriTemplate.fromTemplate("/{model}/{id}") + .set("model", meta.pluralName()) + .set("id", id) + .expand(); + } + + protected String memberUri(String id, String action) { + return UriTemplate.fromTemplate("/{model}/{id}/{action}") + .set("model", meta.pluralName()) + .set("id", id) + .set("action", action) + .expand(); + } + + @Override + public ListenableFuture> all() { + return client().get(collectionUri(), meta.multiResponseType(), HttpOption.INFINITE_RETRY); + } + + @Override + public ListenableFuture find(String id) { + return client().get(memberUri(id), meta.completeType(), HttpOption.INFINITE_RETRY); + } + + @Override + public ListenableFuture> find(FindRequest request) { + return client().post(findMultiUri(), request, meta.multiResponseType(), HttpOption.INFINITE_RETRY); + } + + @Override + public ListenableFuture> find(Collection ids) { + return find(new FindMultiRequest(meta.partialTypeRaw(), ids)); + } +} diff --git a/API/api/src/main/java/tc/oc/api/model/IdFactory.java b/API/api/src/main/java/tc/oc/api/model/IdFactory.java new file mode 100644 index 0000000..696334c --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/model/IdFactory.java @@ -0,0 +1,12 @@ +package tc.oc.api.model; + +import java.util.UUID; + +/** + * TODO: This should probably be pluggable + */ +public class IdFactory { + public String newId() { + return UUID.randomUUID().toString(); + } +} diff --git a/API/api/src/main/java/tc/oc/api/model/ModelBinder.java b/API/api/src/main/java/tc/oc/api/model/ModelBinder.java new file mode 100644 index 0000000..546378f --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/model/ModelBinder.java @@ -0,0 +1,152 @@ +package tc.oc.api.model; + +import java.util.Map; +import java.util.Set; +import javax.inject.Singleton; + +import com.google.inject.Binder; +import com.google.inject.Provides; +import com.google.inject.TypeLiteral; +import com.google.inject.binder.LinkedBindingBuilder; +import com.google.inject.multibindings.Multibinder; +import com.google.inject.multibindings.OptionalBinder; +import tc.oc.api.docs.virtual.Model; +import tc.oc.api.docs.virtual.PartialModel; +import tc.oc.api.connectable.ConnectableBinder; +import tc.oc.api.queue.QueueQueryService; +import tc.oc.commons.core.inject.KeyedManifest; +import tc.oc.commons.core.inject.SingletonManifest; +import tc.oc.commons.core.reflect.ResolvableType; +import tc.oc.commons.core.reflect.TypeArgument; +import tc.oc.commons.core.reflect.TypeLiterals; +import tc.oc.commons.core.stream.Collectors; +import tc.oc.commons.core.util.ImmutableTypeMap; +import tc.oc.commons.core.util.TypeMap; +import tc.oc.inject.ProtectedBinder; +import tc.oc.minecraft.suspend.SuspendableBinder; + +public class ModelBinder implements ModelTypeLiterals, TypeLiterals { + + private final TypeLiteral M; + private final TypeLiteral

P; + private final Binder binder; + private final Multibinder metas; + private final OptionalBinder> queryServiceBinder; + private final OptionalBinder> updateServiceBinder; + private final OptionalBinder> serviceBinder; + private final OptionalBinder> storeBinder; + + public static ModelBinder of(ProtectedBinder binder, Class M) { + return of(binder, M, M); + } + + public static ModelBinder of(ProtectedBinder binder, TypeLiteral M) { + return of(binder, M, M); + } + + public static ModelBinder of(ProtectedBinder binder, Class M, Class

P) { + return of(binder, TypeLiteral.get(M), TypeLiteral.get(P)); + } + + public static ModelBinder of(ProtectedBinder binder, TypeLiteral M, TypeLiteral

P) { + return new ModelBinder<>(binder, M, P); + } + + private ModelBinder(ProtectedBinder protectedBinder, TypeLiteral M, TypeLiteral

P) { + this.binder = protectedBinder.publicBinder(); + this.M = M; + this.P = P; + + this.metas = Multibinder.newSetBinder(binder, ModelMeta.class); + this.serviceBinder = OptionalBinder.newOptionalBinder(binder, ModelService(M, P)); + this.queryServiceBinder = OptionalBinder.newOptionalBinder(binder, QueryService(M)); + this.updateServiceBinder = OptionalBinder.newOptionalBinder(binder, UpdateService(P)); + this.storeBinder = OptionalBinder.newOptionalBinder(binder, ModelStore(M)); + + binder.install(new OneTime()); + binder.install(new PerModel()); + } + + public LinkedBindingBuilder> bindStore() { + new ConnectableBinder(binder).addBinding().to(ModelStore(M)); + new SuspendableBinder(binder).addBinding().to(ModelStore(M)); + return storeBinder.setBinding(); + } + + public OptionalBinder> queryService() { + return queryServiceBinder; + } + + public OptionalBinder> updateService() { + return updateServiceBinder; + } + + public LinkedBindingBuilder> bindDefaultService() { + queryService().setDefault().to(ModelService(M, P)); + updateService().setDefault().to(ModelService(M, P)); + return serviceBinder.setDefault(); + } + + public LinkedBindingBuilder> bindService() { + queryService().setBinding().to(ModelService(M, P)); + updateService().setBinding().to(ModelService(M, P)); + return serviceBinder.setBinding(); + } + + public TypeLiteral> nullService() { + return NullModelService(M, P); + } + + public TypeLiteral> nullQueryService() { + return NullQueryService(M); + } + + public TypeLiteral> httpService() { + return HttpModelService(M, P); + } + + public TypeLiteral> httpQueryService() { + return HttpQueryService(M); + } + + public TypeLiteral> queueQueryService() { + return QueueQueryService(M); + } + + private class PerModel extends KeyedManifest { + @Override + protected Object manifestKey() { + return M; + } + + @Override + protected void configure() { + final TypeLiteral> meta = ModelMeta(M, P); + metas.addBinding().to(meta); + bind(meta).in(Singleton.class); + bind(new ResolvableType>(){}.with(new TypeArgument(M){})).to(meta); + bind(new ResolvableType>(){}.with(new TypeArgument

(P){})).to(meta); + } + } + + private class OneTime extends SingletonManifest { + @Provides @Singleton + Map byName(Set metas) { + return metas.stream().collect(Collectors.indexingBy(ModelMeta::name)); + } + + @Provides @Singleton + TypeMap byType(Set metas) { + final ImmutableTypeMap.Builder builder = ImmutableTypeMap.builder(); + metas.forEach(meta -> builder.put(meta.completeType(), meta)); + return builder.build(); + } + + @Provides @Singleton + TypeMap byPartialType(Set metas) { + final ImmutableTypeMap.Builder builder = ImmutableTypeMap.builder(); + metas.forEach(meta -> builder.put(meta.partialType(), meta)); + return builder.build(); + } + } +} diff --git a/API/api/src/main/java/tc/oc/api/model/ModelBinders.java b/API/api/src/main/java/tc/oc/api/model/ModelBinders.java new file mode 100644 index 0000000..bb4c07f --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/model/ModelBinders.java @@ -0,0 +1,43 @@ +package tc.oc.api.model; + +import java.util.function.Consumer; + +import com.google.inject.TypeLiteral; +import tc.oc.api.docs.virtual.Model; +import tc.oc.api.docs.virtual.PartialModel; +import tc.oc.commons.core.inject.ProtectedBinders; + +public interface ModelBinders extends ProtectedBinders, ModelTypeLiterals { + + default ModelBinder bindModel(Class M) { + return ModelBinder.of(this, M); + } + + default ModelBinder bindModel(TypeLiteral M) { + return ModelBinder.of(this, M); + } + + default ModelBinder bindModel(Class M, Class

P) { + return ModelBinder.of(this, M, P); + } + + default ModelBinder bindModel(TypeLiteral M, TypeLiteral

P) { + return ModelBinder.of(this, M, P); + } + + default void bindModel(Class M, Consumer> block) { + block.accept(ModelBinder.of(this, M)); + } + + default void bindModel(TypeLiteral M, Consumer> block) { + block.accept(ModelBinder.of(this, M)); + } + + default void bindModel(Class M, Class

P, Consumer> block) { + block.accept(ModelBinder.of(this, M, P)); + } + + default void bindModel(TypeLiteral M, TypeLiteral

P, Consumer> block) { + block.accept(ModelBinder.of(this, M, P)); + } +} diff --git a/API/api/src/main/java/tc/oc/api/model/ModelDispatcher.java b/API/api/src/main/java/tc/oc/api/model/ModelDispatcher.java new file mode 100644 index 0000000..421ca91 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/model/ModelDispatcher.java @@ -0,0 +1,76 @@ +package tc.oc.api.model; + +import java.util.Map; +import java.util.Set; +import java.util.logging.Logger; +import javax.annotation.Nullable; +import javax.inject.Inject; +import javax.inject.Singleton; + +import com.google.common.collect.HashMultimap; +import com.google.common.collect.Maps; +import com.google.common.collect.SetMultimap; +import com.google.common.reflect.TypeParameter; +import com.google.common.reflect.TypeToken; +import com.google.inject.ImplementedBy; +import tc.oc.api.docs.virtual.Model; +import tc.oc.commons.core.logging.Loggers; +import tc.oc.commons.core.reflect.Annotations; +import tc.oc.commons.core.reflect.Methods; +import tc.oc.commons.core.util.TypeMap; + +@ImplementedBy(ModelDispatcherImpl.class) +public interface ModelDispatcher { + + void subscribe(ModelListener listener); + + void unsubscribe(ModelListener listener); + + void modelUpdated(@Nullable Model before, @Nullable Model after, Model latest); +} + +@Singleton +class ModelDispatcherImpl implements ModelDispatcher { + + private final Logger logger; + private final TypeMap handlers = TypeMap.create(); + private final SetMultimap, ModelHandler>> listeners = HashMultimap.create(); + + private static TypeToken> handlerType(TypeToken model) { + return new TypeToken>(){}.where(new TypeParameter(){}, model); + } + + @Inject ModelDispatcherImpl(Loggers loggers, TypeMap staticHandlers, Set staticListeners) { + this.logger = loggers.get(getClass()); + handlers.putAll(staticHandlers); // Add all statically bound ModelHandlers + staticListeners.forEach(this::subscribe); // Subscribe all statically bound ModelListeners + } + + @Override + public void subscribe(ModelListener listener) { + final TypeToken listenerType = TypeToken.of(listener.getClass()); + + Methods.declaredMethodsInAncestors(listener.getClass()) + .filter(Annotations.annotatedWith(ModelListener.HandleModel.class)).forEach(method -> { + + final TypeToken model = (TypeToken) listenerType.method(method).getParameters().get(0).getType(); + final ModelHandler handler = Methods.lambda(handlerType(model), method, listener); + handlers.put(model, handler); + listeners.put(listener, Maps.immutableEntry(model, handler)); + + logger.fine(() -> "Dispatching " + model + " updates to " + Methods.describe(listener.getClass(), method.getName())); + }); + } + + @Override + public void unsubscribe(ModelListener listener) { + listeners.removeAll(listener).forEach(entry -> handlers.remove(entry.getKey(), entry.getValue())); + } + + @Override + public void modelUpdated(@Nullable Model before, @Nullable Model after, Model latest) { + for(ModelHandler handler : handlers.allAssignableFrom(latest.getClass())) { + handler.modelUpdated(before, after, latest); + } + } +} diff --git a/API/api/src/main/java/tc/oc/api/model/ModelHandler.java b/API/api/src/main/java/tc/oc/api/model/ModelHandler.java new file mode 100644 index 0000000..6f938b0 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/model/ModelHandler.java @@ -0,0 +1,17 @@ +package tc.oc.api.model; + +import javax.annotation.Nullable; + +import tc.oc.api.docs.virtual.Model; + +@FunctionalInterface +public interface ModelHandler { + /** + * Called when any instance of the model is created, modified, or deleted. + * + * @param before State before the change, or null if instance is being created + * @param after State after the change, or null if instance is being hard-deleted + * @param latest Most recent non-deleted state (equal to before on deletion, otherwise equal to after) + */ + void modelUpdated(@Nullable T before, @Nullable T after, T latest); +} diff --git a/API/api/src/main/java/tc/oc/api/model/ModelListener.java b/API/api/src/main/java/tc/oc/api/model/ModelListener.java new file mode 100644 index 0000000..fef8d5e --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/model/ModelListener.java @@ -0,0 +1,9 @@ +package tc.oc.api.model; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +public interface ModelListener { + @Retention(RetentionPolicy.RUNTIME) + @interface HandleModel {} +} diff --git a/API/api/src/main/java/tc/oc/api/model/ModelListenerBinder.java b/API/api/src/main/java/tc/oc/api/model/ModelListenerBinder.java new file mode 100644 index 0000000..548dae8 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/model/ModelListenerBinder.java @@ -0,0 +1,32 @@ +package tc.oc.api.model; + +import com.google.inject.Binder; +import com.google.inject.TypeLiteral; +import com.google.inject.binder.LinkedBindingBuilder; +import tc.oc.api.docs.virtual.Model; +import tc.oc.commons.core.inject.SetBinder; +import tc.oc.commons.core.inject.TypeMapBinder; + +public class ModelListenerBinder { + + private final TypeMapBinder handlers; + private final SetBinder listeners; + + public ModelListenerBinder(Binder binder) { + binder = binder.skipSources(ModelListenerBinder.class); + this.handlers = new TypeMapBinder(binder){}; + this.listeners = new SetBinder(binder){}; + } + + public LinkedBindingBuilder> bindHandler(Class model) { + return (LinkedBindingBuilder) handlers.addBinding(model); + } + + public LinkedBindingBuilder> bindHandler(TypeLiteral model) { + return (LinkedBindingBuilder) handlers.addBinding(model); + } + + public LinkedBindingBuilder bindListener() { + return listeners.addBinding(); + } +} diff --git a/API/api/src/main/java/tc/oc/api/model/ModelMeta.java b/API/api/src/main/java/tc/oc/api/model/ModelMeta.java new file mode 100644 index 0000000..2bcf366 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/model/ModelMeta.java @@ -0,0 +1,105 @@ +package tc.oc.api.model; + +import java.util.Optional; +import javax.inject.Inject; +import javax.inject.Provider; + +import com.google.common.reflect.TypeParameter; +import com.google.common.reflect.TypeToken; +import com.google.inject.TypeLiteral; +import tc.oc.api.docs.virtual.Model; +import tc.oc.api.docs.virtual.PartialModel; +import tc.oc.api.message.types.FindMultiResponse; +import tc.oc.api.message.types.ModelUpdate; +import tc.oc.api.message.types.PartialModelUpdate; +import tc.oc.commons.core.formatting.StringUtils; +import tc.oc.commons.core.reflect.Types; + +import static tc.oc.commons.core.reflect.Types.assertFullySpecified; + +public class ModelMeta { + + private final TypeToken

partialType; + private final TypeToken completeType; + + private final TypeToken> partialUpdateType; + private final TypeToken> completeUpdateType; + private final TypeToken> multiResponseType; + + private final Provider>> store; + + private final String name; + private final String singularName; + private final String pluralName; + + @Inject ModelMeta(TypeLiteral completeType, TypeLiteral

partialType, Provider>> store) { + this.partialType = Types.toToken(partialType); + this.completeType = Types.toToken(completeType); + + partialUpdateType = assertFullySpecified(new TypeToken>(){}.where(new TypeParameter

(){}, this.partialType)); + completeUpdateType = assertFullySpecified(new TypeToken>(){}.where(new TypeParameter(){}, this.completeType)); + multiResponseType = assertFullySpecified(new TypeToken>(){}.where(new TypeParameter(){}, this.completeType)); + + this.store = store; + + final ModelName annot = completeType.getRawType().getAnnotation(ModelName.class); + if(annot != null) { + name = annot.value(); + singularName = annot.singular().length() > 0 ? annot.singular() : name.toLowerCase(); + pluralName = annot.plural().length() > 0 ? annot.plural() : StringUtils.pluralize(singularName); + } else { + name = completeType.getRawType().getSimpleName(); + singularName = name.toLowerCase(); + pluralName = StringUtils.pluralize(singularName); + } + } + + public TypeToken

partialType() { + return partialType; + } + + public TypeToken completeType() { + return completeType; + } + + public Class

partialTypeRaw() { + return (Class

) partialType().getRawType(); + } + + public Class completeTypeRaw() { + return (Class) completeType().getRawType(); + } + + public TypeToken> partialUpdateType() { + return partialUpdateType; + } + + public TypeToken> completeUpdateType() { + return completeUpdateType; + } + + public TypeToken> multiResponseType() { + return multiResponseType; + } + + public Optional> store() { + return store.get(); + } + + public String name() { + return name; + } + + public String singularName() { + return singularName; + } + + public String pluralName() { + return pluralName; + } + + public interface Builder { + Builder partial(Class type); + Builder store(Class> type); + } +} diff --git a/API/api/src/main/java/tc/oc/api/model/ModelName.java b/API/api/src/main/java/tc/oc/api/model/ModelName.java new file mode 100644 index 0000000..752c885 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/model/ModelName.java @@ -0,0 +1,11 @@ +package tc.oc.api.model; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +@Retention(RetentionPolicy.RUNTIME) +public @interface ModelName { + String value(); + String singular() default ""; + String plural() default ""; +} diff --git a/API/api/src/main/java/tc/oc/api/model/ModelRegistry.java b/API/api/src/main/java/tc/oc/api/model/ModelRegistry.java new file mode 100644 index 0000000..bbddf30 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/model/ModelRegistry.java @@ -0,0 +1,55 @@ +package tc.oc.api.model; + +import java.util.Collection; +import java.util.Comparator; +import java.util.Map; +import java.util.NoSuchElementException; +import javax.inject.Inject; + +import com.google.common.base.Splitter; +import com.google.common.reflect.TypeToken; +import tc.oc.api.docs.virtual.PartialModel; +import tc.oc.api.exceptions.SerializationException; +import tc.oc.commons.core.util.AmbiguousElementException; +import tc.oc.commons.core.util.TypeMap; + +public class ModelRegistry { + + private final TypeMap byType; + private final Map byName; + + @Inject ModelRegistry(TypeMap byType, Map byName) { + this.byType = byType; + this.byName = byName; + } + + public ModelMeta meta(Class model) { + final TypeToken tt = TypeToken.of(model); + return meta(tt); + } + + public ModelMeta meta(TypeToken model) { + try { + return byType.oneAssignableFrom(model); + } catch(NoSuchElementException e) { + throw new SerializationException(model.getRawType().getName() + " is not a registered model"); + } catch(AmbiguousElementException e) { + throw new SerializationException(model.getRawType().getName() + " extends from multiple models"); + } + } + + public ModelMeta resolve(String query) { + return Splitter.on("::") // Extract embedded Ruby classes + .splitToList(query) + .stream() + .sorted(Comparator.reverseOrder()) // Give priority to descendants + .filter(byName::containsKey) + .map(byName::get) + .findFirst() + .orElseThrow(() -> new NoSuchModelException("No registered model named '" + query + "'")); + } + + public Collection all() { + return byName.values(); + } +} diff --git a/API/api/src/main/java/tc/oc/api/model/ModelService.java b/API/api/src/main/java/tc/oc/api/model/ModelService.java new file mode 100644 index 0000000..edd8615 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/model/ModelService.java @@ -0,0 +1,23 @@ +package tc.oc.api.model; + +import com.google.common.util.concurrent.ListenableFuture; +import tc.oc.api.docs.virtual.Model; +import tc.oc.api.docs.virtual.PartialModel; +import tc.oc.api.message.types.PartialModelUpdate; + +public interface ModelService + extends QueryService, UpdateService { + + @Override + ListenableFuture update(String id, PartialModelUpdate request); + + @Override + default ListenableFuture update(String id, Partial partial) { + return (ListenableFuture) UpdateService.super.update(id, partial); + } + + @Override + default ListenableFuture update(Partial partial) { + return (ListenableFuture) UpdateService.super.update(partial); + } +} diff --git a/API/api/src/main/java/tc/oc/api/model/ModelStore.java b/API/api/src/main/java/tc/oc/api/model/ModelStore.java new file mode 100644 index 0000000..7c16cf4 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/model/ModelStore.java @@ -0,0 +1,257 @@ +package tc.oc.api.model; + +import java.io.IOException; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import java.util.function.Predicate; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import javax.annotation.Nullable; +import javax.inject.Inject; + +import com.google.common.cache.LoadingCache; +import com.google.common.collect.ImmutableSet; +import com.google.common.reflect.TypeToken; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import tc.oc.api.connectable.Connectable; +import tc.oc.api.docs.virtual.DeletableModel; +import tc.oc.api.docs.virtual.Model; +import tc.oc.api.document.DocumentGenerator; +import tc.oc.api.message.MessageListener; +import tc.oc.api.message.MessageQueue; +import tc.oc.api.message.types.FindMultiResponse; +import tc.oc.api.message.types.FindRequest; +import tc.oc.api.message.types.ModelDelete; +import tc.oc.api.message.types.ModelUpdate; +import tc.oc.commons.core.concurrent.FutureUtils; +import tc.oc.commons.core.logging.Loggers; +import tc.oc.commons.core.util.CacheUtils; +import tc.oc.commons.core.util.ProxyUtils; +import tc.oc.minecraft.suspend.Suspendable; + +public abstract class ModelStore implements MessageListener, Connectable, Suspendable { + + protected Logger logger; + protected @Inject QueryService queryService; + protected @Inject MessageQueue primaryQueue; + protected @Inject @ModelSync ExecutorService modelSync; + protected @Inject ModelDispatcher dispatcher; + + protected ModelMeta meta; + private LoadingCache proxies; + + private final Map byId = new HashMap<>(); + + @Inject void init(Loggers loggers, ModelRegistry registry) { + this.logger = loggers.get(getClass()); + this.meta = (ModelMeta) registry.meta(new TypeToken(getClass()){}); + + this.proxies = CacheUtils.newCache(id -> ProxyUtils.newProviderProxy(meta.completeTypeRaw(), () -> byId(id))); + } + + @Override + public void connect() throws IOException { + primaryQueue.bind(ModelUpdate.class); + primaryQueue.bind(ModelDelete.class); + + primaryQueue.subscribe(this, modelSync); + refreshAllSync(); + } + + @Override + public void disconnect() throws IOException { + primaryQueue.unsubscribe(this); + } + + @Override + public void resume() { + refreshAll(); + } + + /** + * Return a transparent proxy for the stored document with the given ID. + * Every method call on the proxy is forwarded to the version of the document + * stored at the time of the call. + * + * The document does not need to actually exist when the proxy is created, + * but it must exist any time a method is called. + * + * TODO: Avoid double-wrapping. In most cases, this will return a proxy to + * another proxy. The inner proxy is from {@link DocumentGenerator} to implement + * the document interface, and the outer proxy is the one created here to look + * it up in the tracker. Would be nice if these were combined into a single proxy. + */ + public T proxy(String id) { + return proxies.getUnchecked(id); + } + + /** + * Return the stored document with the given ID + * + * @throws IllegalStateException if there is no stored document with the given ID + */ + public T byId(String id) { + final T doc = byId.get(id); + if(doc == null) { + throw new IllegalStateException("Missing " + meta.name() + " " + id); + } + return doc; + } + + public Optional tryId(String id) { + return Optional.ofNullable(byId.get(id)); + } + + public int count() { + return byId.size(); + } + + public Stream all() { + return byId.values().stream(); + } + + public Set set() { + return ImmutableSet.copyOf(byId.values()); + } + + public Set subset(Predicate filter) { + return byId.values().stream().filter(filter).collect(Collectors.toSet()); + } + + public @Nullable T first(Comparator order, @Nullable Predicate filter) { + T min = null; + for(T doc : byId.values()) { + if((filter == null || filter.test(doc)) && (min == null || order.compare(doc, min) < 0)) { + min = doc; + } + } + return min; + } + + protected void logAction(String verb, T model) { + if(logger.isLoggable(Level.FINE)) { + logger.fine(verb + ' ' + meta.name() + ' ' + model._id()); + } + } + + protected FindRequest refreshAllRequest() { + return new FindRequest<>(meta.completeType()); + } + + protected ListenableFuture> sendRefreshAll() { + logger.fine("Requesting refresh of all documents"); + return queryService.find(refreshAllRequest()); + } + + public ListenableFuture> refreshAll() { + return FutureUtils.mapSync( + sendRefreshAll(), + response -> handleRefreshAll(response, Runnable::run), + modelSync + ); + } + + /** + * Must be called on the ModelSync thread + */ + protected FindMultiResponse refreshAllSync() { + // At startup, defer notifications to the next tick, after all ModelStores are populated, + // because some handlers will try to lookup foreign keys across stores. + return handleRefreshAll(Futures.getUnchecked(sendRefreshAll()), modelSync); + } + + protected FindMultiResponse handleRefreshAll(FindMultiResponse response, Executor notificationExecutor) { + logger.fine(() -> "Storing " + response.documents().size() + " documents"); + response.documents().forEach(after -> handleUpdate(after._id(), after, notificationExecutor)); + final Set gone = new HashSet<>(byId.values()); + gone.removeAll(response.documents()); + gone.forEach(doc -> handleUpdate(doc._id(), null, notificationExecutor)); + return response; + } + + @HandleMessage + public void onUpdate(ModelUpdate message) { + handleUpdate(message.document()._id(), message.document()); + } + + @HandleMessage + public void onDelete(ModelDelete message) { + handleUpdate(message.document_id(), null); + } + + private boolean exists(@Nullable T doc) { + return doc != null && !(doc instanceof DeletableModel && ((DeletableModel) doc).dead()); + } + + private void handleUpdate(String id, @Nullable T after) { + handleUpdate(id, after, Runnable::run); + } + + private void handleUpdate(String id, @Nullable T after, Executor notificationExecutor) { + final T before = byId.get(id); + final T latest; + + if(exists(before)) { + if(exists(after)) { + logAction("Update", after); + unindex(before); + reindex(after); + latest = after; + } else { + logAction("Delete", before); + unindex(before); + remove(before); + latest = before; + } + } else if(exists(after)) { + logAction("Create", after); + reindex(after); + latest = after; + } else { + latest = null; + } + + if(exists(latest)) { + notificationExecutor.execute(() -> dispatcher.modelUpdated(before, after, latest)); + } + } + + /** + * Called only when a document is deleted, after {@link #unindex}. + * + * Subclasses only need to override this method if they need to do some extra + * cleanup, besides unindexing. + */ + protected void remove(T doc) { + proxies.invalidate(doc._id()); + } + + /** + * Called when a document is updated, or deleted. The argument is the state + * of the document before the change. + * + * Subclasses can override this in order to maintain their own indexes. + */ + protected void unindex(T doc) { + byId.remove(doc._id()); + } + + /** + * This method is called when a document is created or updated. The argument + * is the state of the document after the change. + * + * Subclasses can override this in order to maintain their own indexes. + */ + protected void reindex(T doc) { + byId.put(doc._id(), doc); + } +} diff --git a/API/api/src/main/java/tc/oc/api/model/ModelSync.java b/API/api/src/main/java/tc/oc/api/model/ModelSync.java new file mode 100644 index 0000000..fb891f7 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/model/ModelSync.java @@ -0,0 +1,21 @@ +package tc.oc.api.model; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.concurrent.ExecutorService; +import javax.inject.Qualifier; + +import com.google.inject.BindingAnnotation; + +/** + * An {@link ExecutorService} annotated with this runs tasks in sync + * with all {@link ModelStore}s, so all data is constant during a + * single execution. + * + * In Bukkit/Bungee environments, the executor simply runs everything + * on the main thread. + */ +@Qualifier +@BindingAnnotation +@Retention(RetentionPolicy.RUNTIME) +public @interface ModelSync {} diff --git a/API/api/src/main/java/tc/oc/api/model/ModelTypeLiterals.java b/API/api/src/main/java/tc/oc/api/model/ModelTypeLiterals.java new file mode 100644 index 0000000..1021a1e --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/model/ModelTypeLiterals.java @@ -0,0 +1,96 @@ +package tc.oc.api.model; + +import com.google.inject.TypeLiteral; +import tc.oc.api.docs.virtual.Model; +import tc.oc.api.docs.virtual.PartialModel; +import tc.oc.api.queue.QueueQueryService; +import tc.oc.commons.core.reflect.ResolvableType; +import tc.oc.commons.core.reflect.TypeArgument; + +public interface ModelTypeLiterals { + + default

TypeLiteral> ModelMeta(TypeLiteral M, TypeLiteral

P) { + return new ResolvableType>(){}.with(new TypeArgument(M){}, + new TypeArgument

(P){}); + } + default

TypeLiteral> ModelMeta(Class M, Class

P) { + return new ResolvableType>(){}.with(new TypeArgument(M){}, + new TypeArgument

(P){}); + } + + default

TypeLiteral> ModelService(TypeLiteral M, TypeLiteral

P) { + return new ResolvableType>(){}.with(new TypeArgument(M){}, + new TypeArgument

(P){}); + } + default

TypeLiteral> ModelService(Class M, Class

P) { + return new ResolvableType>(){}.with(new TypeArgument(M){}, + new TypeArgument

(P){}); + } + + default

TypeLiteral> NullModelService(TypeLiteral M, TypeLiteral

P) { + return new ResolvableType>(){}.with(new TypeArgument(M){}, + new TypeArgument

(P){}); + } + default

TypeLiteral> NullModelService(Class M, Class

P) { + return new ResolvableType>(){}.with(new TypeArgument(M){}, + new TypeArgument

(P){}); + } + + default

TypeLiteral> HttpModelService(TypeLiteral M, TypeLiteral

P) { + return new ResolvableType>(){}.with(new TypeArgument(M){}, + new TypeArgument

(P){}); + } + default

TypeLiteral> HttpModelService(Class M, Class

P) { + return new ResolvableType>(){}.with(new TypeArgument(M){}, + new TypeArgument

(P){}); + } + + default TypeLiteral> QueryService(TypeLiteral M) { + return new ResolvableType>(){}.with(new TypeArgument(M){}); + } + default TypeLiteral> QueryService(Class M) { + return new ResolvableType>(){}.with(new TypeArgument(M){}); + } + + default TypeLiteral> NullQueryService(TypeLiteral M) { + return new ResolvableType>(){}.with(new TypeArgument(M){}); + } + default TypeLiteral> NullQueryService(Class M) { + return new ResolvableType>(){}.with(new TypeArgument(M){}); + } + + default TypeLiteral> HttpQueryService(TypeLiteral M) { + return new ResolvableType>(){}.with(new TypeArgument(M){}); + } + default TypeLiteral> HttpQueryService(Class M) { + return new ResolvableType>(){}.with(new TypeArgument(M){}); + } + + default TypeLiteral> QueueQueryService(TypeLiteral M) { + return new ResolvableType>(){}.with(new TypeArgument(M){}); + } + default TypeLiteral> QueueQueryService(Class M) { + return new ResolvableType>(){}.with(new TypeArgument(M){}); + } + + default

TypeLiteral> UpdateService(TypeLiteral

P) { + return new ResolvableType>(){}.with(new TypeArgument

(P){}); + } + default

TypeLiteral> UpdateService(Class

P) { + return new ResolvableType>(){}.with(new TypeArgument

(P){}); + } + + default

TypeLiteral> BatchUpdater(TypeLiteral

P) { + return new ResolvableType>(){}.with(new TypeArgument

(P){}); + } + default

TypeLiteral> BatchUpdater(Class

P) { + return new ResolvableType>(){}.with(new TypeArgument

(P){}); + } + + default TypeLiteral> ModelStore(TypeLiteral M) { + return new ResolvableType>(){}.with(new TypeArgument(M){}); + } + default TypeLiteral> ModelStore(Class M) { + return new ResolvableType>(){}.with(new TypeArgument(M){}); + } +} diff --git a/API/api/src/main/java/tc/oc/api/model/ModelsManifest.java b/API/api/src/main/java/tc/oc/api/model/ModelsManifest.java new file mode 100644 index 0000000..9ed6788 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/model/ModelsManifest.java @@ -0,0 +1,35 @@ +package tc.oc.api.model; + +import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; +import javax.inject.Singleton; + +import com.google.common.util.concurrent.ListeningExecutorService; +import com.google.common.util.concurrent.MoreExecutors; +import com.google.inject.Key; +import com.google.inject.Provides; +import tc.oc.commons.core.inject.HybridManifest; + +public class ModelsManifest extends HybridManifest implements ModelBinders { + + @Provides @Singleton @ModelSync + ListeningExecutorService listeningModelSync(@ModelSync ExecutorService modelSync) { + return MoreExecutors.listeningDecorator(modelSync); + } + + @Override + protected void configure() { + // @ModelSync ExecutorService must be bound elsewhere + final Key executorKey = Key.get(Executor.class, ModelSync.class); + final Key executorServiceKey = Key.get(ExecutorService.class, ModelSync.class); + final Key listeningExecutorServiceKey = Key.get(ListeningExecutorService.class, ModelSync.class); + + bind(executorKey).to(executorServiceKey); + + expose(executorKey); + expose(executorServiceKey); + expose(listeningExecutorServiceKey); + + new ModelListenerBinder(publicBinder()); + } +} diff --git a/API/api/src/main/java/tc/oc/api/model/NoSuchModelException.java b/API/api/src/main/java/tc/oc/api/model/NoSuchModelException.java new file mode 100644 index 0000000..68de97f --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/model/NoSuchModelException.java @@ -0,0 +1,9 @@ +package tc.oc.api.model; + +import tc.oc.api.exceptions.SerializationException; + +public class NoSuchModelException extends SerializationException { + public NoSuchModelException(String message) { + super(message); + } +} diff --git a/API/api/src/main/java/tc/oc/api/model/NullModelService.java b/API/api/src/main/java/tc/oc/api/model/NullModelService.java new file mode 100644 index 0000000..351c862 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/model/NullModelService.java @@ -0,0 +1,45 @@ +package tc.oc.api.model; + +import java.util.Collections; +import javax.inject.Inject; + +import com.google.common.reflect.TypeToken; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import tc.oc.api.docs.virtual.Model; +import tc.oc.api.docs.virtual.PartialModel; +import tc.oc.api.message.types.FindMultiResponse; +import tc.oc.api.message.types.FindRequest; +import tc.oc.api.message.types.PartialModelUpdate; +import tc.oc.api.message.types.UpdateMultiRequest; +import tc.oc.api.message.types.UpdateMultiResponse; + +public class NullModelService implements ModelService { + + @Inject private ModelMeta meta; + + @Override + public TypeToken completeType() { + return meta.completeType(); + } + + @Override + public TypeToken partialType() { + return meta.partialType(); + } + + @Override + public ListenableFuture> find(FindRequest request) { + return Futures.immediateFuture(Collections::emptyList); + } + + @Override + public ListenableFuture update(String id, PartialModelUpdate request) { + return find(id); + } + + @Override + public ListenableFuture updateMulti(UpdateMultiRequest request) { + return Futures.immediateFuture(UpdateMultiResponse.EMPTY); + } +} diff --git a/API/api/src/main/java/tc/oc/api/model/NullQueryService.java b/API/api/src/main/java/tc/oc/api/model/NullQueryService.java new file mode 100644 index 0000000..24a7fd6 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/model/NullQueryService.java @@ -0,0 +1,26 @@ +package tc.oc.api.model; + +import java.util.Collections; +import javax.inject.Inject; + +import com.google.common.reflect.TypeToken; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import tc.oc.api.docs.virtual.Model; +import tc.oc.api.message.types.FindMultiResponse; +import tc.oc.api.message.types.FindRequest; + +public class NullQueryService implements QueryService { + + @Inject private ModelMeta meta; + + @Override + public TypeToken completeType() { + return meta.completeType(); + } + + @Override + public ListenableFuture> find(FindRequest request) { + return Futures.immediateFuture(Collections::emptyList); + } +} diff --git a/API/api/src/main/java/tc/oc/api/model/QueryService.java b/API/api/src/main/java/tc/oc/api/model/QueryService.java new file mode 100644 index 0000000..075c46a --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/model/QueryService.java @@ -0,0 +1,38 @@ +package tc.oc.api.model; + +import java.util.Collection; +import java.util.Collections; + +import com.google.common.reflect.TypeToken; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import tc.oc.api.docs.virtual.Model; +import tc.oc.api.exceptions.NotFound; +import tc.oc.api.message.types.FindMultiRequest; +import tc.oc.api.message.types.FindMultiResponse; +import tc.oc.api.message.types.FindRequest; +import tc.oc.commons.core.concurrent.FutureUtils; + +public interface QueryService { + + TypeToken completeType(); + + ListenableFuture> find(FindRequest request); + + default ListenableFuture> all() { + return find(new FindRequest<>(completeType())); + } + + default ListenableFuture find(String id) { + return FutureUtils.mapAsync( + find(Collections.singleton(id)), + response -> response.documents().stream().findAny() + .map(Futures::immediateFuture) + .orElseGet(() -> Futures.immediateFailedFuture(new NotFound(completeType().toString() + " with id " + id, null))) + ); + } + + default ListenableFuture> find(Collection ids) { + return find(new FindMultiRequest<>(completeType(), ids)); + } +} diff --git a/API/api/src/main/java/tc/oc/api/model/UpdateService.java b/API/api/src/main/java/tc/oc/api/model/UpdateService.java new file mode 100644 index 0000000..cc7f517 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/model/UpdateService.java @@ -0,0 +1,46 @@ +package tc.oc.api.model; + +import java.util.Collection; + +import com.google.common.reflect.TypeToken; +import com.google.common.util.concurrent.ListenableFuture; +import tc.oc.api.docs.virtual.Model; +import tc.oc.api.docs.virtual.PartialModel; +import tc.oc.api.message.types.PartialModelUpdate; +import tc.oc.api.message.types.UpdateMultiRequest; +import tc.oc.api.message.types.UpdateMultiResponse; + +public interface UpdateService { + + TypeToken partialType(); + + default PartialModelUpdate updateRequest(Partial document) { + return new PartialModelUpdate() { + @Override public Partial document() { return document; } + @Override public TypeToken model() { return partialType(); } + }; + } + + ListenableFuture update(String id, PartialModelUpdate request); + + default ListenableFuture update(String id, Partial partial) { + return update(id, updateRequest(partial)); + } + + default ListenableFuture update(Partial partial) { + if(!(partial instanceof Model)) { + throw new IllegalArgumentException("Partial model has no _id field"); + } + Model model = (Model) partial; + if(model._id() == null) { + throw new IllegalArgumentException("_id is null"); + } + return update(model._id(), partial); + } + + default ListenableFuture updateMulti(Collection models) { + return updateMulti((UpdateMultiRequest) () -> models); + } + + ListenableFuture updateMulti(UpdateMultiRequest request); +} diff --git a/API/api/src/main/java/tc/oc/api/punishments/PunishmentModelManifest.java b/API/api/src/main/java/tc/oc/api/punishments/PunishmentModelManifest.java new file mode 100644 index 0000000..258a9b1 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/punishments/PunishmentModelManifest.java @@ -0,0 +1,16 @@ +package tc.oc.api.punishments; + +import tc.oc.api.docs.Punishment; +import tc.oc.api.docs.virtual.PunishmentDoc; +import tc.oc.api.model.ModelBinders; +import tc.oc.commons.core.inject.HybridManifest; + +public class PunishmentModelManifest extends HybridManifest implements ModelBinders { + + @Override + protected void configure() { + bindModel(Punishment.class, PunishmentDoc.Partial.class, model -> { + model.bindDefaultService().to(model.nullService()); + }); + } +} diff --git a/API/api/src/main/java/tc/oc/api/punishments/PunishmentSearchRequest.java b/API/api/src/main/java/tc/oc/api/punishments/PunishmentSearchRequest.java new file mode 100644 index 0000000..656019f --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/punishments/PunishmentSearchRequest.java @@ -0,0 +1,33 @@ +package tc.oc.api.punishments; + +import tc.oc.api.annotations.Serialize; +import tc.oc.api.docs.PlayerId; +import tc.oc.api.docs.Punishment; +import tc.oc.api.message.types.FindRequest; + +import javax.annotation.Nullable; + +@Serialize +public class PunishmentSearchRequest extends FindRequest { + + private final @Nullable String punisher; + private final @Nullable String punished; + private final @Nullable Boolean active; + private final @Nullable Integer limit; + + private PunishmentSearchRequest(@Nullable PlayerId punisher, @Nullable PlayerId punished, @Nullable Boolean active, @Nullable Integer limit) { + this.punisher = punisher == null ? null : punisher._id(); + this.punished = punished == null ? null : punished._id(); + this.active = active; + this.limit = limit; + } + + public static PunishmentSearchRequest punisher(PlayerId punisher, @Nullable Integer limit) { + return new PunishmentSearchRequest(punisher, null, null, limit); + } + + public static PunishmentSearchRequest punished(PlayerId punished, @Nullable Boolean active, @Nullable Integer limit) { + return new PunishmentSearchRequest(null, punished, active, limit); + } + +} diff --git a/API/api/src/main/java/tc/oc/api/queue/Consume.java b/API/api/src/main/java/tc/oc/api/queue/Consume.java new file mode 100644 index 0000000..1ac3c1c --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/queue/Consume.java @@ -0,0 +1,41 @@ +package tc.oc.api.queue; + +import java.util.Collections; +import java.util.Map; +import javax.annotation.Nullable; + +public class Consume { + private final String name; + private final boolean durable; + private final boolean exclusive; + private final boolean autoDelete; + private final Map arguments; + + public Consume(String name, boolean durable, boolean exclusive, boolean autoDelete, @Nullable Map arguments) { + this.name = name; + this.durable = durable; + this.exclusive = exclusive; + this.autoDelete = autoDelete; + this.arguments = arguments != null ? arguments : Collections.emptyMap(); + } + + public String name() { + return name; + } + + public boolean durable() { + return durable; + } + + public boolean exclusive() { + return exclusive; + } + + public boolean autoDelete() { + return autoDelete; + } + + public Map arguments() { + return arguments; + } +} diff --git a/API/api/src/main/java/tc/oc/api/queue/Delivery.java b/API/api/src/main/java/tc/oc/api/queue/Delivery.java new file mode 100644 index 0000000..3718b4f --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/queue/Delivery.java @@ -0,0 +1,39 @@ +package tc.oc.api.queue; + +import java.io.IOException; +import java.util.logging.Level; + +import com.rabbitmq.client.Envelope; + +/** + * Information about the delivery of an incoming message. + * + * Also allows delivery to be acknowledged by calling {@link #ack()}. + */ +public class Delivery { + private final QueueClient client; + private final String consumerTag; + private final Envelope envelope; + + public Delivery(QueueClient client, String consumerTag, Envelope envelope) { + this.client = client; + this.consumerTag = consumerTag; + this.envelope = envelope; + } + + public String consumerTag() { + return consumerTag; + } + + public Envelope envelope() { + return envelope; + } + + public void ack() { + try { + client.getChannel().basicAck(envelope().getDeliveryTag(), false); + } catch(IOException e) { + client.getLogger().log(Level.SEVERE, "Failed to ACK delivery " + this, e); + } + } +} diff --git a/API/api/src/main/java/tc/oc/api/queue/Exchange.java b/API/api/src/main/java/tc/oc/api/queue/Exchange.java new file mode 100644 index 0000000..a0ae20c --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/queue/Exchange.java @@ -0,0 +1,125 @@ +package tc.oc.api.queue; + +import java.io.IOException; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.logging.Logger; +import javax.annotation.Nullable; +import javax.inject.Inject; +import javax.inject.Singleton; + +import com.google.common.util.concurrent.ListenableFuture; +import com.rabbitmq.client.BasicProperties; +import tc.oc.api.connectable.Connectable; +import tc.oc.api.message.Message; +import tc.oc.commons.core.logging.Loggers; + +import static com.google.common.base.Preconditions.checkNotNull; + +public class Exchange implements Connectable { + + @Singleton + public static class Direct extends Exchange { + Direct() { + super("ocn.direct", "direct", true, false, false, null); + } + } + + @Singleton + public static class Fanout extends Exchange { + Fanout() { + super("ocn.fanout", "fanout", true, false, false, null); + } + } + + @Singleton + public static class Topic extends Exchange { + Topic() { + super("ocn.topic", "topic", true, false, false, null); + } + } + + protected Logger logger; + protected @Inject QueueClient client; + + private final String name; + private final String type; + private final boolean durable; + private final boolean autoDelete; + private final boolean internal; + private final Map arguments; + + public String name() { return name; } + public String type() { return type; } + public boolean durable() { return durable; } + public boolean autoDelete() { return autoDelete; } + public boolean internal() { return internal; } + public Map arguments() { return arguments; } + + private Exchange(String name, String type, boolean durable, boolean autoDelete, boolean internal, Map arguments) { + this.name = checkNotNull(name); + this.type = checkNotNull(type); + this.durable = durable; + this.autoDelete = autoDelete; + this.internal = internal; + this.arguments = arguments == null ? null : Collections.unmodifiableMap(new HashMap<>(arguments)); + } + + @Inject void init(Loggers loggers) { + this.logger = loggers.get(getClass(), name); + } + + @Override + public void connect() throws IOException { + logger.fine("Declaring " + type() + " exchange"); + client.getChannel().exchangeDeclare(name(), type(), durable(), autoDelete(), internal(), arguments()); + } + + @Override + public void disconnect() throws IOException {} + + public void publishSync(Message message) { + publishSync(message, null, (Publish) null); + } + + public void publishSync(Message message, @Nullable Publish publish) { + publishSync(message, null, publish); + } + + public void publishSync(Message message, @Nullable BasicProperties properties) { + publishSync(message, properties, (Publish) null); + } + + public void publishSync(Message message, @Nullable BasicProperties properties, String routingKey) { + publishSync(message, properties, new Publish(routingKey)); + } + + public void publishSync(Message message, @Nullable BasicProperties properties, @Nullable Publish publish) { + client.publishSync(this, message, properties, publish); + } + + public ListenableFuture publishAsync(Message message) { + return publishAsync(message, null, (Publish) null); + } + + public ListenableFuture publishAsync(Message message, String routingKey) { + return publishAsync(message, null, new Publish(routingKey)); + } + + public ListenableFuture publishAsync(Message message, @Nullable Publish publish) { + return publishAsync(message, null, publish); + } + + public ListenableFuture publishAsync(Message message, @Nullable BasicProperties properties) { + return publishAsync(message, properties, (Publish) null); + } + + public ListenableFuture publishAsync(Message message, @Nullable BasicProperties properties, String routingKey) { + return publishAsync(message, properties, new Publish(routingKey)); + } + + public ListenableFuture publishAsync(Message message, @Nullable BasicProperties properties, @Nullable Publish publish) { + return client.publishAsync(this, message, properties, publish); + } +} diff --git a/API/api/src/main/java/tc/oc/api/queue/MessageDefaults.java b/API/api/src/main/java/tc/oc/api/queue/MessageDefaults.java new file mode 100644 index 0000000..6cf1f15 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/queue/MessageDefaults.java @@ -0,0 +1,21 @@ +package tc.oc.api.queue; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +public interface MessageDefaults { + @Retention(RetentionPolicy.RUNTIME) + @interface RoutingKey { + String value(); + } + + @Retention(RetentionPolicy.RUNTIME) + @interface Persistent { + boolean value(); + } + + @Retention(RetentionPolicy.RUNTIME) + @interface ExpirationMillis { + int value(); + } +} diff --git a/API/api/src/main/java/tc/oc/api/queue/Metadata.java b/API/api/src/main/java/tc/oc/api/queue/Metadata.java new file mode 100644 index 0000000..7556422 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/queue/Metadata.java @@ -0,0 +1,209 @@ +package tc.oc.api.queue; + +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import javax.annotation.Nullable; + +import com.rabbitmq.client.AMQP; +import com.rabbitmq.client.BasicProperties; +import java.time.Duration; +import java.time.Instant; +import tc.oc.api.config.ApiConstants; + +public class Metadata extends AMQP.BasicProperties { + + // Custom headers + public static final String PROTOCOL_VERSION = "protocol_version"; + public static final String MODEL_NAME = "model_name"; + + public static Map nonNullHeaders(Map headers) { + return headers != null ? headers : Collections.emptyMap(); + } + + public static Map getHeaders(AMQP.BasicProperties props) { + return nonNullHeaders(props.getHeaders()); + } + + @Override + public Map getHeaders() { + return nonNullHeaders(super.getHeaders()); + } + + public static @Nullable String getHeaderString(AMQP.BasicProperties props, String name) { + // Header values are some kind of spooky fake string object + // called LongStringHelper.ByteArrayLongString + Object o = getHeaders(props).get(name); + return o == null ? null : o.toString(); + } + + public @Nullable String getHeaderString(String name) { + return getHeaderString(this, name); + } + + public static int getHeaderInt(AMQP.BasicProperties props, String name, int def) { + final String text = getHeaderString(props, name); + return text == null ? def : Integer.parseInt(text); + } + + public int getHeaderInt(String name, int def) { + return getHeaderInt(this, name, def); + } + + public static int protocolVersion(AMQP.BasicProperties props) { + return getHeaderInt(props, PROTOCOL_VERSION, ApiConstants.PROTOCOL_VERSION); + } + + public int protocolVersion() { + return protocolVersion(this); + } + + public static Optional modelName(AMQP.BasicProperties props) { + return Optional.ofNullable(getHeaderString(props, MODEL_NAME)); + } + + public Optional modelName() { + return modelName(this); + } + + public @Nullable Duration expiration() { + final String expiration = getExpiration(); + return expiration == null ? null : Duration.ofMillis(Long.parseLong(expiration)); + } + + public @Nullable Instant timestamp() { + final Date timestamp = getTimestamp(); + return timestamp == null ? null : timestamp.toInstant(); + } + + public @Nullable Instant expiresAt() { + final Instant timestamp = timestamp(); + final Duration expiration = expiration(); + if(timestamp != null && expiration != null) { + return timestamp.plus(expiration); + } + return null; + } + + public Metadata(String contentType, String contentEncoding, Map headers, Integer deliveryMode, Integer priority, String correlationId, String replyTo, String expiration, String messageId, Date timestamp, String type, String userId, String appId) { + super(contentType, contentEncoding, headers, deliveryMode, priority, correlationId, replyTo, expiration, messageId, timestamp, type, userId, appId, null); + } + + public Metadata(BasicProperties p) { + this(p.getContentType(), p.getContentEncoding(), p.getHeaders(), p.getDeliveryMode(), p.getPriority(), p.getCorrelationId(), p.getReplyTo(), p.getExpiration(), p.getMessageId(), p.getTimestamp(), p.getType(), p.getUserId(), p.getAppId()); + } + + public static class Builder { + private String contentType; + private String contentEncoding; + private Map headers; + private Integer deliveryMode; + private Integer priority; + private String correlationId; + private String replyTo; + private String expiration; + private String messageId; + private Date timestamp; + private String type; + private String userId; + private String appId; + + public Builder() {}; + + public Builder(@Nullable BasicProperties p) { + if(p == null) return; + this.contentType = p.getContentType(); + this.contentEncoding = p.getContentEncoding(); + this.headers = p.getHeaders(); + this.deliveryMode = p.getDeliveryMode(); + this.priority = p.getPriority(); + this.correlationId = p.getCorrelationId(); + this.replyTo = p.getReplyTo(); + this.expiration = p.getExpiration(); + this.timestamp = p.getTimestamp(); + this.type = p.getType(); + this.userId = p.getUserId(); + this.appId = p.getAppId(); + }; + + public Builder expiration(Duration expiration) { + return expiration(String.valueOf(expiration.toMillis())); + } + + public Builder persistent(boolean persistent) { + return deliveryMode(persistent ? 2 : 1); + } + + public Builder timestamp(Instant timestamp) { + return timestamp(Date.from(timestamp)); + } + + public Builder replyTo(Queue queue) { + return replyTo(queue.name()); + } + + public Builder header(String key, String value) { + if(headers == null) { + headers = new HashMap<>(); + } + headers.put(key, value); + return this; + } + + public Builder header(String key, int value) { + return header(key, String.valueOf(value)); + } + + public Builder protocolVersion(int version) { + return header("protocol_version", version); + } + + // Commence copypasta + public Builder contentType(String contentType) + { this.contentType = contentType; return this; } + public Builder contentEncoding(String contentEncoding) + { this.contentEncoding = contentEncoding; return this; } + public Builder headers(Map headers) + { this.headers = headers; return this; } + public Builder deliveryMode(Integer deliveryMode) + { this.deliveryMode = deliveryMode; return this; } + public Builder priority(Integer priority) + { this.priority = priority; return this; } + public Builder correlationId(String correlationId) + { this.correlationId = correlationId; return this; } + public Builder replyTo(String replyTo) + { this.replyTo = replyTo; return this; } + public Builder expiration(String expiration) + { this.expiration = expiration; return this; } + public Builder messageId(String messageId) + { this.messageId = messageId; return this; } + public Builder timestamp(Date timestamp) + { this.timestamp = timestamp; return this; } + public Builder type(String type) + { this.type = type; return this; } + public Builder userId(String userId) + { this.userId = userId; return this; } + public Builder appId(String appId) + { this.appId = appId; return this; } + + public Metadata build() { + return new Metadata + ( contentType + , contentEncoding + , headers + , deliveryMode + , priority + , correlationId + , replyTo + , expiration + , messageId + , timestamp + , type + , userId + , appId + ); + } + } +} diff --git a/API/api/src/main/java/tc/oc/api/queue/PrimaryQueue.java b/API/api/src/main/java/tc/oc/api/queue/PrimaryQueue.java new file mode 100644 index 0000000..9dbad48 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/queue/PrimaryQueue.java @@ -0,0 +1,30 @@ +package tc.oc.api.queue; + +import java.io.IOException; +import javax.inject.Inject; +import javax.inject.Singleton; + +import tc.oc.api.config.ApiConfiguration; + +/** + * The primary queue aka server queue aka reply queue + */ +@Singleton +public class PrimaryQueue extends Queue { + + private final Exchange.Direct direct; + private final Exchange.Fanout fanout; + + @Inject PrimaryQueue(ApiConfiguration config, Exchange.Direct direct, Exchange.Fanout fanout) { + super(new Consume(config.primaryQueueName(), false, false, true, null)); + this.direct = direct; + this.fanout = fanout; + } + + @Override + public void connect() throws IOException { + super.connect(); + bind(direct, name()); + bind(fanout, ""); + } +} diff --git a/API/api/src/main/java/tc/oc/api/queue/Publish.java b/API/api/src/main/java/tc/oc/api/queue/Publish.java new file mode 100644 index 0000000..99b7841 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/queue/Publish.java @@ -0,0 +1,60 @@ +package tc.oc.api.queue; + +import javax.annotation.Nullable; + +import tc.oc.api.message.Message; +import tc.oc.commons.core.reflect.Types; + +import static com.google.common.base.Preconditions.checkNotNull; + +/** + * Options for publishing a {@link Message} through a {@link Queue} + */ +public class Publish { + public static final Publish DEFAULT = new Publish(); + + private final String routingKey; + private final boolean mandatory; + private final boolean immediate; + + public Publish(String routingKey, boolean mandatory, boolean immediate) { + this.routingKey = checkNotNull(routingKey); + this.mandatory = mandatory; + this.immediate = immediate; + } + + public Publish(String routingKey) { + this(routingKey, false, false); + } + + public Publish() { + this("", false, false); + } + + public String routingKey() { + return routingKey; + } + + public boolean mandatory() { + return mandatory; + } + + public boolean immediate() { + return immediate; + } + + public static Publish forMessage(Message message, @Nullable Publish publish) { + if(publish == null) { + publish = Publish.DEFAULT; + } + + if("".equals(publish.routingKey())) { + MessageDefaults.RoutingKey routingKey = Types.inheritableAnnotation(message.getClass(), MessageDefaults.RoutingKey.class); + if(routingKey != null) { + publish = new Publish(routingKey.value(), publish.mandatory(), publish.immediate()); + } + } + + return publish; + } +} diff --git a/API/api/src/main/java/tc/oc/api/queue/Queue.java b/API/api/src/main/java/tc/oc/api/queue/Queue.java new file mode 100644 index 0000000..3846e35 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/queue/Queue.java @@ -0,0 +1,359 @@ +package tc.oc.api.queue; + +import java.io.IOException; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Type; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.logging.Level; +import java.util.logging.Logger; +import javax.annotation.Nullable; +import javax.inject.Inject; + +import com.google.common.base.Charsets; +import com.google.common.reflect.TypeToken; +import com.google.common.util.concurrent.SettableFuture; +import com.google.gson.Gson; +import com.rabbitmq.client.AMQP; +import com.rabbitmq.client.DefaultConsumer; +import com.rabbitmq.client.Envelope; +import tc.oc.api.connectable.Connectable; +import tc.oc.api.message.Message; +import tc.oc.api.message.MessageHandler; +import tc.oc.api.message.MessageListener; +import tc.oc.api.message.MessageQueue; +import tc.oc.api.message.MessageRegistry; +import tc.oc.api.message.NoSuchMessageException; +import tc.oc.api.serialization.Pretty; +import tc.oc.commons.core.exception.ExceptionHandler; +import tc.oc.commons.core.logging.Loggers; +import tc.oc.commons.core.reflect.Methods; +import tc.oc.commons.core.reflect.Types; +import tc.oc.commons.core.util.CachingTypeMap; +import tc.oc.minecraft.suspend.Suspendable; + +/** + * An AMQP queue. + * + * Declaration and binding (note that Queue can be instantiated without an AMQP connection): + * + * final Queue MY_QUEUE = new Queue("my_queue", true, false, false, null); + * MY_QUEUE.declare(Api.get().queueClient()); + * + * Binding: + * + * MY_QUEUE.bind(Exchange.DIRECT); // queue name is default routing key + * MY_QUEUE.bind(Exchange.FANOUT); + * MY_QUEUE.bind(Exchange.TOPIC, "fun_stuff"); + * + * Subscription with {@link MessageHandler}: + * + * MY_QUEUE.subscribe(ServerReconfigure.class, new MessageHandler() { + * @Override + * public void handleDelivery(ServerReconfigure message, Delivery delivery) { + * // ... + * } + * }, false, syncExecutor); + * + * Subscription with {@link MessageListener}: + * + * class MyWorker implements MessageListener { + * @HandleMessage + * public void handleHealthReport(ServerHealthReport message, Delivery delivery) { + * // ... + * } + * + * @HandleMessage + * public void handlePlayerReport(ServerPlayerReport message, Delivery delivery) { + * // ... + * } + * } + * + * MY_QUEUE.subscribe(new MyWorker(), syncExecutor); + */ +public class Queue implements MessageQueue, Connectable, Suspendable { + + private static class RegisteredHandler { + final @Nullable MessageListener listener; + final MessageHandler handler; + final @Nullable Executor executor; + + private RegisteredHandler(@Nullable MessageListener listener, MessageHandler handler, @Nullable Executor executor) { + this.listener = listener; + this.handler = handler; + this.executor = executor; + } + } + + protected Logger logger; + @Inject protected MessageRegistry messageRegistry; + @Inject protected Gson gson; + @Inject @Pretty protected Gson prettyGson; + @Inject protected QueueClient client; + @Inject protected Exchange.Topic topic; + @Inject protected ExceptionHandler exceptionHandler; + + protected final Consume consume; + + @Nullable String consumerTag; + private MultiDispatcher dispatcher; + + private final Set> handlers = new HashSet<>(); + private final CachingTypeMap> handlersByType = CachingTypeMap.create(); + private volatile boolean suspended; + + public Consume consume() { return consume; } + public String name() { return consume.name(); } + public QueueClient client() { return client; } + + public Queue(Consume consume) { + this.consume = consume; + } + + @Inject void init(Loggers loggers) { + logger = loggers.get(getClass()); + } + + @Override + public void connect() throws IOException { + logger.fine("Declaring queue"); + client.getChannel().queueDeclare(consume.name(), consume.durable(), consume.exclusive(), consume.autoDelete(), consume.arguments()); + dispatcher = new MultiDispatcher(); + consumerTag = client.getChannel().basicConsume(consume.name(), false, "", false, true, Collections.emptyMap(), dispatcher); + } + + @Override + public void disconnect() throws IOException { + if(dispatcher != null) { + if(consumerTag != null) { + client.getChannel().basicCancel(consumerTag); + consumerTag = null; + } + try { + dispatcher.awaitTermination(5L, TimeUnit.SECONDS); + } catch(InterruptedException | ExecutionException | TimeoutException e) { + throw new IOException("Failed to shutdown " + this, e); + } + } + } + + @Override + public void suspend() { + suspended = true; + } + + @Override + public void resume() { + suspended = false; + } + + protected void bind(Exchange exchange, String routingKey) { + try { + logger.fine("Binding to exchange " + exchange.name() + " with routing key " + routingKey); + + client.getChannel().queueBind(name(), exchange.name(), routingKey, null); + } catch(IOException e) { + throw new IllegalStateException("Failed to bind to exchange " + exchange.name(), e); + } + } + + @Override + public void bind(Class type) { + bind(topic, messageRegistry.typeName(type)); + } + + @Override + public void subscribe(TypeToken messageType, MessageHandler handler, @Nullable Executor executor) { + subscribe(messageType, null, handler, executor); + } + + private void subscribe(TypeToken messageType, @Nullable MessageListener listener, MessageHandler handler, @Nullable Executor executor) { + logger.fine("Subscribing handler " + handler); + synchronized(handlers) { + final RegisteredHandler registered = new RegisteredHandler<>(listener, handler, executor); + handlers.add(registered); + handlersByType.put(messageType, registered); + handlersByType.invalidate(); + } + } + + private TypeToken getMessageType(TypeToken decl, Method method) { + if(method.getParameterTypes().length < 1 || method.getParameterTypes().length > 3) { + throw new IllegalStateException("Message handler method must take 1 to 3 parameters"); + } + + final TypeToken base = new TypeToken(){}; + + for(Type param : method.getGenericParameterTypes()) { + final TypeToken paramToken = decl.resolveType(param); + Types.assertFullySpecified(paramToken); + if(base.isAssignableFrom(paramToken)) { + messageRegistry.typeName(paramToken.getRawType()); // Verify message type is registered + return paramToken; + } + } + + throw new IllegalStateException("Message handler has no message parameter"); + } + + @Override + public void subscribe(final MessageListener listener, @Nullable Executor executor) { + logger.fine("Subscribing listener " + listener); + + final TypeToken listenerType = TypeToken.of(listener.getClass()); + Methods.declaredMethodsInAncestors(listener.getClass()).forEach(method -> { + final MessageListener.HandleMessage annot = method.getAnnotation(MessageListener.HandleMessage.class); + if(annot != null) { + method.setAccessible(true); + final TypeToken messageType = getMessageType(listenerType, method); + + logger.fine(" dispatching " + messageType.getRawType().getSimpleName() + " to method " + method.getName()); + + MessageHandler handler = new MessageHandler() { + @Override + public void handleDelivery(Message message, TypeToken type, Metadata properties, Delivery delivery) { + try { + if(annot.protocolVersion() != -1 && annot.protocolVersion() != properties.protocolVersion()) { + return; + } + + final Class[] paramTypes = method.getParameterTypes(); + Object[] params = new Object[paramTypes.length]; + for(int i = 0; i < paramTypes.length; i++) { + if(paramTypes[i].isAssignableFrom(message.getClass())) { + params[i] = message; + } else if(paramTypes[i].isAssignableFrom(Metadata.class)) { + params[i] = properties; + } else if(paramTypes[i].isAssignableFrom(Delivery.class)) { + params[i] = delivery; + } + } + method.invoke(listener, params); + } catch(IllegalAccessException e) { + throw new IllegalStateException(e); + } catch(InvocationTargetException e) { + if(e.getCause() instanceof RuntimeException) { + throw (RuntimeException) e.getCause(); + } else { + throw new IllegalStateException(e); + } + } + } + + @Override + public String toString() { + return listener + "." + method.getName(); + } + }; + + subscribe(messageType, listener, handler, executor); + } + }); + } + + @Override + public void unsubscribe(MessageHandler handler) { + synchronized(handlers) { + handlers.removeIf(registered -> registered.handler == handler); + handlersByType.entries().removeIf(registered -> registered.getValue().handler == handler); + } + } + + @Override + public void unsubscribe(MessageListener listener) { + if(listener == null) return; + synchronized(handlers) { + handlers.removeIf(registered -> registered.listener == listener); + handlersByType.entries().removeIf(registered -> registered.getValue().listener == listener); + } + } + + private class MultiDispatcher extends DefaultConsumer { + + private SettableFuture cancelled; + + MultiDispatcher() { + super(client.getChannel()); + } + + void awaitTermination(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException { + if(cancelled != null) { + cancelled.get(timeout, unit); + } + } + + @Override + public void handleConsumeOk(String consumerTag) { + cancelled = SettableFuture.create(); + } + + @Override + public void handleCancelOk(String consumerTag) { + if(cancelled != null) { + cancelled.set(true); + } + } + + @Override + public void handleDelivery(String consumerTag, Envelope envelope, final AMQP.BasicProperties amqProperties, byte[] body) throws IOException { + try { + client.getChannel().basicAck(envelope.getDeliveryTag(), false); + + final TypeToken type; + try { + type = messageRegistry.resolve(amqProperties.getType(), Metadata.modelName(amqProperties)); + } catch(NoSuchMessageException e) { + // Probably a newer protocol + logger.warning("Skipping unknown message type: " + e.getMessage()); + return; + } + + final Collection> matchingHandlers; + synchronized(handlers) { + matchingHandlers = handlersByType.allAssignableFrom(type); + } + if(matchingHandlers.isEmpty()) return; + + final String json = new String(body, Charsets.UTF_8); + final Message message = gson.fromJson(json, type.getType()); + final Metadata properties = new Metadata(amqProperties); + final Delivery delivery = new Delivery(client, consumerTag, envelope); + + if(logger.isLoggable(Level.FINE)) { + logger.fine("Received message " + properties.getType() + "\nMetadata: " + properties + "\n" + prettyGson.toJson(json)); + } + + for(final RegisteredHandler handler : matchingHandlers) { + if(suspended && handler.listener != null && + !handler.listener.listenWhileSuspended()) continue; + + logger.fine("Dispatching " + amqProperties.getType() + " to " + handler.handler.getClass()); + if(handler.executor == null) { + exceptionHandler.run(() -> handler.handler.handleDelivery(message, type, properties, delivery)); + } else { + handler.executor.execute(() -> { + synchronized(handlers) { + // Double check from the handler's executor that it is still registered. + // This makes it much less likely to dispatch a message to a handler + // after it unsubs. It should work perfectly if the handler unsubs on + // the same thread it handles messages on. + if(!handlers.contains(handler)) return; + } + exceptionHandler.run(() -> handler.handler.handleDelivery(message, type, properties, delivery)); + }); + } + } + } catch(Throwable t) { + logger.log(Level.SEVERE, "Exception dispatching AMQP message", t); + // Don't let any exceptions through to the AMQP driver or it will close the channel + } + } + } +} diff --git a/API/api/src/main/java/tc/oc/api/queue/QueueClient.java b/API/api/src/main/java/tc/oc/api/queue/QueueClient.java new file mode 100644 index 0000000..800735b --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/queue/QueueClient.java @@ -0,0 +1,235 @@ +package tc.oc.api.queue; + +import java.io.IOException; +import java.util.Date; +import java.util.Map; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; +import java.util.logging.Level; +import java.util.logging.Logger; +import javax.annotation.Nullable; +import javax.inject.Inject; +import javax.inject.Singleton; + +import com.google.common.base.Charsets; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.ListeningExecutorService; +import com.google.common.util.concurrent.MoreExecutors; +import com.google.common.util.concurrent.ThreadFactoryBuilder; +import com.google.gson.Gson; +import com.rabbitmq.client.AMQP; +import com.rabbitmq.client.Address; +import com.rabbitmq.client.BasicProperties; +import com.rabbitmq.client.Channel; +import com.rabbitmq.client.Connection; +import com.rabbitmq.client.ConnectionFactory; +import java.time.Duration; +import tc.oc.api.connectable.Connectable; +import tc.oc.api.config.ApiConstants; +import tc.oc.api.message.Message; +import tc.oc.api.message.MessageRegistry; +import tc.oc.api.message.types.ModelMessage; +import tc.oc.api.model.IdFactory; +import tc.oc.api.model.ModelRegistry; +import tc.oc.commons.core.concurrent.ExecutorUtils; +import tc.oc.commons.core.logging.Loggers; +import tc.oc.commons.core.reflect.Types; +import tc.oc.commons.core.util.Joiners; +import tc.oc.commons.core.util.MapUtils; + +import static com.google.common.base.Preconditions.checkNotNull; + +@Singleton +public class QueueClient implements Connectable { + + private static final Duration SHUTDOWN_TIMEOUT = Duration.ofSeconds(10); + + protected static final Metadata DEFAULT_PROPERTIES = new Metadata.Builder() + .appId("ocn") + .contentType("application/json") + .contentEncoding("utf-8") + .protocolVersion(ApiConstants.PROTOCOL_VERSION) + .build(); + + protected final Logger logger; + + private final QueueClientConfiguration config; + private final @Nullable ThreadFactory threadFactory; + private final Gson gson; + private final MessageRegistry messageRegistry; + private final ModelRegistry modelRegistry; + private final IdFactory idFactory; + + private Connection connection; + private Channel channel; + private final ListeningExecutorService executorService; + + @Inject QueueClient(Loggers loggers, QueueClientConfiguration config, Gson gson, MessageRegistry messageRegistry, ModelRegistry modelRegistry, IdFactory idFactory) { + this.modelRegistry = modelRegistry; + this.logger = loggers.get(getClass()); + this.idFactory = idFactory; + this.config = checkNotNull(config, "config"); + this.gson = checkNotNull(gson, "GSON"); + this.messageRegistry = checkNotNull(messageRegistry, "message registry"); + this.threadFactory = new ThreadFactoryBuilder().setNameFormat("API AMQP Executor").build(); + + if (config.getThreads() > 0) { + this.executorService = MoreExecutors.listeningDecorator(Executors.newFixedThreadPool(config.getThreads())); + } else { + this.executorService = MoreExecutors.listeningDecorator(Executors.newCachedThreadPool(threadFactory)); + } + } + + public Logger getLogger() { + return logger; + } + + public Channel getChannel() { + if(channel == null) { + throw new IllegalStateException("QueueClient is not connected"); + } + return channel; + } + + private static Metadata cloneProperties(Metadata properties) { + try { + return (Metadata) properties.clone(); + } catch(CloneNotSupportedException e) { + throw new IllegalStateException(e); + } + } + + private static void mergeProperties(Metadata to, BasicProperties from) { + if(from.getMessageId() != null) to.setMessageId(from.getMessageId()); + if(from.getDeliveryMode() != null) to.setDeliveryMode(from.getDeliveryMode()); + if(from.getExpiration() != null) to.setExpiration(from.getExpiration()); + if(from.getCorrelationId() != null) to.setCorrelationId(from.getCorrelationId()); + if(from.getReplyTo() != null) to.setReplyTo(from.getReplyTo()); + + final Map headers = from.getHeaders(); + if(headers != null && !headers.isEmpty()) { + to.setHeaders(MapUtils.merge(to.getHeaders(), headers)); + } + } + + public Metadata getProperties(Message message, @Nullable BasicProperties properties) { + Metadata amqp = cloneProperties(DEFAULT_PROPERTIES); + + amqp.setMessageId(idFactory.newId()); + amqp.setTimestamp(new Date()); + + amqp.setType(messageRegistry.typeName(message.getClass())); + if(message instanceof ModelMessage) { + amqp.setHeaders(MapUtils.merge(amqp.getHeaders(), + Metadata.MODEL_NAME, + modelRegistry.meta(((ModelMessage) message).model()).name())); + } + + MessageDefaults.ExpirationMillis expiration = Types.inheritableAnnotation(message.getClass(), MessageDefaults.ExpirationMillis.class); + if(expiration != null) { + amqp.setExpiration(String.valueOf(expiration.value())); + } + + MessageDefaults.Persistent persistent = Types.inheritableAnnotation(message.getClass(), MessageDefaults.Persistent.class); + if(persistent != null) { + amqp.setDeliveryMode(persistent.value() ? 2 : 1); + } + + if(properties != null) mergeProperties(amqp, properties); + + return amqp; + } + + public Publish getPublish(Message message, @Nullable Publish publish) { + if(publish == null) { + publish = Publish.DEFAULT; + } + + if("".equals(publish.routingKey())) { + MessageDefaults.RoutingKey routingKey = Types.inheritableAnnotation(message.getClass(), MessageDefaults.RoutingKey.class); + if(routingKey != null) { + publish = new Publish(routingKey.value(), publish.mandatory(), publish.immediate()); + } + } + + return publish; + } + + private void publish(Exchange exchange, String payload, AMQP.BasicProperties properties, @Nullable Publish publish) { + if(logger.isLoggable(Level.FINE)) { + logger.fine("Publishing to exchange " + exchange.name() + + " with routing key " + publish.routingKey() + + " and properties " + properties + + ":\n" + payload); + } + + try { + this.channel.basicPublish(exchange.name(), + publish.routingKey(), + publish.mandatory(), + publish.immediate(), + properties, + payload.getBytes(Charsets.UTF_8)); + } catch(IOException e) { + logger.log(Level.SEVERE, + "Failed to publish message of type " + properties.getType() + + " to exchange '" + exchange + "' with routing key '" + publish.routingKey() + "'", + e); + } + } + + public void publishSync(Exchange exchange, Message message, @Nullable BasicProperties properties, @Nullable Publish publish) { + publish(exchange, gson.toJson(message), getProperties(message, properties), Publish.forMessage(message, publish)); + } + + public ListenableFuture publishAsync(final Exchange exchange, final Message message, final @Nullable BasicProperties properties, final @Nullable Publish publish) { + // NOTE: Serialization must happen synchronously, because getter methods may not be thread-safe + final String payload = gson.toJson(message); + final AMQP.BasicProperties finalProperties = getProperties(message, properties); + final Publish finalPublish = Publish.forMessage(message, publish); + + if(this.executorService == null) throw new IllegalStateException("Not connected"); + return this.executorService.submit(new Runnable() { + @Override + public void run() { + try { + publish(exchange, payload, finalProperties, finalPublish); + } catch(Throwable e) { + logger.log(Level.SEVERE, "Unhandled exception publishing message type " + finalProperties.getType(), e); + } + } + }); + } + + private ConnectionFactory createConnectionFactory() throws IOException { + ConnectionFactory factory = new ConnectionFactory(); + factory.setUsername(this.config.getUsername()); + factory.setPassword(this.config.getPassword()); + factory.setVirtualHost(this.config.getVirtualHost()); + + factory.setAutomaticRecoveryEnabled(true); + factory.setConnectionTimeout(this.config.getConnectionTimeout()); + factory.setNetworkRecoveryInterval(this.config.getNetworkRecoveryInterval()); + + if (this.threadFactory != null) { + factory.setThreadFactory(this.threadFactory); + } + + return factory; + } + + @Override + public void connect() throws IOException { + // create connection and channel + logger.info("Connecting to AMQP API at " + Joiners.onCommaSpace.join(config.getAddresses())); + this.connection = this.createConnectionFactory().newConnection(this.config.getAddresses().toArray(new Address[0])); + this.channel = this.connection.createChannel(); + } + + @Override + public void disconnect() throws IOException { + ExecutorUtils.shutdownImpatiently(executorService, logger, SHUTDOWN_TIMEOUT); + this.channel.close(); + this.connection.close(); + } +} diff --git a/API/api/src/main/java/tc/oc/api/queue/QueueClientConfiguration.java b/API/api/src/main/java/tc/oc/api/queue/QueueClientConfiguration.java new file mode 100644 index 0000000..b79d391 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/queue/QueueClientConfiguration.java @@ -0,0 +1,42 @@ +package tc.oc.api.queue; + +import java.util.List; + +import com.rabbitmq.client.Address; + +public interface QueueClientConfiguration { + + /** + * Addresses to connect to in order. If connection to one fails try the + * next one in the list. + */ + List

getAddresses(); + + /** + * Name of user to connect as. + */ + String getUsername(); + + /** + * Password of user to connect as. + */ + String getPassword(); + + /** + * Virtual host to connect to. + */ + String getVirtualHost(); + + /** + * The connection timeout; zero means to wait indefinitely. + */ + int getConnectionTimeout(); + + /** + * How long will automatic recovery wait before attempting to reconnect in + * milliseconds. + */ + int getNetworkRecoveryInterval(); + + int getThreads(); +} diff --git a/API/api/src/main/java/tc/oc/api/queue/QueueClientConfigurationImpl.java b/API/api/src/main/java/tc/oc/api/queue/QueueClientConfigurationImpl.java new file mode 100644 index 0000000..9797be3 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/queue/QueueClientConfigurationImpl.java @@ -0,0 +1,65 @@ +package tc.oc.api.queue; + +import java.util.List; +import java.util.stream.Collectors; +import javax.inject.Inject; + +import com.rabbitmq.client.Address; +import tc.oc.minecraft.api.configuration.Configuration; +import tc.oc.minecraft.api.configuration.ConfigurationSection; + +import static com.google.common.base.Preconditions.checkNotNull; + +public class QueueClientConfigurationImpl implements QueueClientConfiguration { + + public static final String SECTION = "queue"; + + public static final String NETWORK_RECOVERY_INTERVAL_PATH = "network-recovery-interval"; + public static final String CONNECTION_TIMEOUT_PATH = "connection-timeout"; + public static final String VIRTUAL_HOST_PATH = "virtual-host"; + public static final String THREADS_PATH = "threads"; + public static final String PASSWORD_PATH = "password"; + public static final String USERNAME_PATH = "username"; + public static final String ADDRESSES_PATH = "addresses"; + + private final ConfigurationSection config; + + @Inject public QueueClientConfigurationImpl(Configuration config) { + this.config = checkNotNull(config.getSection(SECTION)); + } + + @Override + public List
getAddresses() { + return config.getStringList(ADDRESSES_PATH).stream().map(Address::new).collect(Collectors.toList()); + } + + @Override + public String getUsername() { + return config.getString(USERNAME_PATH); + } + + @Override + public String getPassword() { + return config.getString(PASSWORD_PATH); + } + + @Override + public String getVirtualHost() { + return config.getString(VIRTUAL_HOST_PATH); + } + + @Override + public int getConnectionTimeout() { + return config.getInt(CONNECTION_TIMEOUT_PATH); + } + + @Override + public int getNetworkRecoveryInterval() { + return config.getInt(NETWORK_RECOVERY_INTERVAL_PATH); + } + + @Override + public int getThreads() { + return config.getInt(THREADS_PATH); + } +} diff --git a/API/api/src/main/java/tc/oc/api/queue/QueueManifest.java b/API/api/src/main/java/tc/oc/api/queue/QueueManifest.java new file mode 100644 index 0000000..1fc1342 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/queue/QueueManifest.java @@ -0,0 +1,36 @@ +package tc.oc.api.queue; + +import tc.oc.api.connectable.ConnectableBinder; +import tc.oc.api.message.MessageQueue; +import tc.oc.commons.core.inject.HybridManifest; +import tc.oc.minecraft.suspend.SuspendableBinder; + +public class QueueManifest extends HybridManifest { + + @Override + protected void configure() { + bindAndExpose(QueueClientConfiguration.class) + .to(QueueClientConfigurationImpl.class); + + bindAndExpose(QueueClient.class); + bindAndExpose(Exchange.Direct.class); + bindAndExpose(Exchange.Fanout.class); + bindAndExpose(Exchange.Topic.class); + bindAndExpose(PrimaryQueue.class); + + publicBinder().forOptional(MessageQueue.class) + .setBinding().to(PrimaryQueue.class); + + // These will connect in the order listed here. + // TODO: figure out the order from their dependencies. + final ConnectableBinder services = new ConnectableBinder(publicBinder()); + services.addBinding().to(QueueClient.class); + services.addBinding().to(Exchange.Direct.class); + services.addBinding().to(Exchange.Fanout.class); + services.addBinding().to(Exchange.Topic.class); + services.addBinding().to(PrimaryQueue.class); + + final SuspendableBinder suspendables = new SuspendableBinder(publicBinder()); + suspendables.addBinding().to(PrimaryQueue.class); + } +} diff --git a/API/api/src/main/java/tc/oc/api/queue/QueueQueryService.java b/API/api/src/main/java/tc/oc/api/queue/QueueQueryService.java new file mode 100644 index 0000000..769077f --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/queue/QueueQueryService.java @@ -0,0 +1,27 @@ +package tc.oc.api.queue; + +import javax.inject.Inject; + +import com.google.common.reflect.TypeToken; +import com.google.common.util.concurrent.ListenableFuture; +import tc.oc.api.docs.virtual.Model; +import tc.oc.api.message.types.FindMultiResponse; +import tc.oc.api.message.types.FindRequest; +import tc.oc.api.model.ModelMeta; +import tc.oc.api.model.QueryService; + +public class QueueQueryService implements QueryService { + + @Inject protected ModelMeta meta; + @Inject private Transaction.Factory transactionFactory; + + @Override + public TypeToken completeType() { + return meta.completeType(); + } + + @Override + public ListenableFuture> find(FindRequest request) { + return transactionFactory.request(request, meta.multiResponseType()); + } +} diff --git a/API/api/src/main/java/tc/oc/api/queue/Transaction.java b/API/api/src/main/java/tc/oc/api/queue/Transaction.java new file mode 100644 index 0000000..ee12b11 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/queue/Transaction.java @@ -0,0 +1,168 @@ +package tc.oc.api.queue; + +import java.util.concurrent.TimeoutException; +import javax.annotation.Nullable; +import javax.inject.Inject; + +import com.google.common.reflect.TypeToken; +import com.google.common.util.concurrent.ListenableFuture; +import java.time.Duration; +import tc.oc.api.exceptions.ApiException; +import tc.oc.api.message.Message; +import tc.oc.api.message.MessageHandler; +import tc.oc.api.message.types.Reply; +import tc.oc.commons.core.concurrent.TimeoutFuture; +import tc.oc.commons.core.util.ExceptionUtils; + +import static com.google.common.base.Preconditions.checkNotNull; + +/** + * This is a {@link ListenableFuture} that sends a {@link Message} to an {@link Exchange} + * and then waits to receive a reply on a given {@link Queue}. The reply-to header on the + * request message is set to the name of the reply queue, and the eventual reply message + * is identified by its correlation-id matching the message-id of the request. There is + * also a timeout, which defaults to 30 seconds, that will cause the future to fail with + * a {@link TimeoutException}. + */ +public class Transaction extends TimeoutFuture { + + public static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(30); + + private final String requestId; + private final Class requestType; + private final Queue replyQueue; + private final MessageHandler messageHandler; + private final StackTraceElement[] callSite; + + public static class Factory { + private final Exchange.Direct exchange; + private final PrimaryQueue primaryQueue; + + @Inject Factory(Exchange.Direct exchange, PrimaryQueue primaryQueue) { + this.exchange = exchange; + this.primaryQueue = primaryQueue; + } + + /** + * @param request Outgoing request message + * @param properties Metadata for the request (null for default properties) + * @param publish Publishing options for the request (null for default options) + * @param replyType Type of the reply message (if the reply isn't assignable to this, it will be missed) + * @param timeout Time to wait for the reply before failing (null for DEFAULT_TIMEOUT) + */ + public Transaction request(Message request, + @Nullable Metadata properties, + @Nullable Publish publish, + TypeToken replyType, + @Nullable Duration timeout) { + + return new Transaction<>(exchange, primaryQueue, request, properties, publish, replyType, timeout); + } + + public Transaction request(Message request, @Nullable Metadata properties, @Nullable Publish publish, Class replyType, @Nullable Duration timeout) { + return request(request, properties, publish, TypeToken.of(replyType), timeout); + } + + public Transaction request(Message request, TypeToken replyType, @Nullable Publish publish) { + return request(request, null, publish, replyType, null); + } + + public Transaction request(Message request, Class replyType, @Nullable Publish publish) { + return request(request, null, publish, replyType, null); + } + + public Transaction request(Message request, TypeToken replyType, String routingKey) { + return request(request, replyType, new Publish(routingKey)); + } + + public Transaction request(Message request, Class replyType, String routingKey) { + return request(request, replyType, new Publish(routingKey)); + } + + public Transaction request(Message request, TypeToken replyType) { + return request(request, replyType, (Publish) null); + } + + public Transaction request(Message request, Class replyType) { + return request(request, replyType, (Publish) null); + } + + public Transaction request(Message request, @Nullable Publish publish) { + return request(request, Reply.class, publish); + } + + public Transaction request(Message request, String routingKey) { + return request(request, new Publish(routingKey)); + } + + public Transaction request(Message request) { + return request(request, (Publish) null); + } + } + + private Transaction(Exchange.Direct exchange, + final Queue replyQueue, + Message request, + @Nullable Metadata requestProps, + @Nullable Publish publish, + TypeToken replyType, + @Nullable Duration timeout) { + + super(timeout != null ? timeout : DEFAULT_TIMEOUT); + + checkNotNull(request, "request"); + checkNotNull(replyType, "replyType"); + + this.callSite = new Exception().getStackTrace(); + this.replyQueue = replyQueue; + + final Metadata finalRequestProps = requestProps = replyQueue.client().getProperties(request, requestProps); + finalRequestProps.setReplyTo(this.replyQueue.name()); + + this.requestId = checkNotNull(finalRequestProps.getMessageId()); + this.requestType = request.getClass(); + + this.messageHandler = new MessageHandler() { + @Override public void handleDelivery(Message message, TypeToken type, Metadata replyProps, Delivery delivery) { + if(requestId.equals(replyProps.getCorrelationId())) { + Transaction.this.replyQueue.unsubscribe(messageHandler); + + if(replyProps.protocolVersion() != finalRequestProps.protocolVersion()) { + setException(new ApiException("Received a protocol " + replyProps.protocolVersion() + + " reply to a protocol " + finalRequestProps.protocolVersion() + " request", + callSite)); + } else { + final Reply reply = message instanceof Reply ? (Reply) message : null; + if(reply != null && !reply.success()) { + setException(new ApiException(reply.error() != null ? reply.error() + : "Generic failure reply to request " + requestId, + callSite)); + } else if(!replyType.isAssignableFrom(type)) { + setException(new ApiException("Wrong reply type to request " + requestId + + ", expected a " + replyType + + ", received a " + type, + callSite)); + } else { + set((T) message); + } + } + } + } + }; + + this.replyQueue.subscribe(Message.class, messageHandler, null); + + exchange.publishAsync(request, finalRequestProps, publish); + } + + @Override + protected void interruptTask() { + replyQueue.unsubscribe(messageHandler); + } + + @Override + protected String makeTimeoutMessage() { + return "Timed out waiting for reply to request " + requestId + " (" + requestType + + ") created at " + ExceptionUtils.formatStackTrace(callSite); + } +} diff --git a/API/api/src/main/java/tc/oc/api/reports/ReportModelManifest.java b/API/api/src/main/java/tc/oc/api/reports/ReportModelManifest.java new file mode 100644 index 0000000..9cb5385 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/reports/ReportModelManifest.java @@ -0,0 +1,16 @@ +package tc.oc.api.reports; + +import tc.oc.api.docs.Report; +import tc.oc.api.docs.virtual.ReportDoc; +import tc.oc.api.model.ModelBinders; +import tc.oc.commons.core.inject.HybridManifest; + +public class ReportModelManifest extends HybridManifest implements ModelBinders { + + @Override + protected void configure() { + bindModel(Report.class, ReportDoc.Partial.class, model -> { + model.bindDefaultService().to(model.nullService()); + }); + } +} diff --git a/API/api/src/main/java/tc/oc/api/reports/ReportSearchRequest.java b/API/api/src/main/java/tc/oc/api/reports/ReportSearchRequest.java new file mode 100644 index 0000000..1066c14 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/reports/ReportSearchRequest.java @@ -0,0 +1,62 @@ +package tc.oc.api.reports; + +import java.util.Collection; +import javax.annotation.Nullable; + +import tc.oc.api.annotations.Serialize; +import tc.oc.api.docs.PlayerId; +import tc.oc.api.docs.Report; +import tc.oc.api.docs.virtual.ServerDoc; +import tc.oc.api.message.types.FindRequest; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkState; + +public class ReportSearchRequest extends FindRequest { + + @Serialize private final @Nullable String server_id; + @Serialize private final @Nullable Collection family_ids; + @Serialize private final @Nullable String user_id; + + private final int page, perPage; + + private ReportSearchRequest(String server_id, Collection family_ids, String user_id, int page, int perPage) { + checkArgument(page > 0); + checkArgument(perPage > 0); + + this.server_id = server_id; + this.family_ids = family_ids; + this.user_id = user_id; + this.page = page; + this.perPage = perPage; + } + + public static ReportSearchRequest create(int page, int perPage) { + return new ReportSearchRequest(null, null, null, page, perPage); + } + + public ReportSearchRequest forServer(ServerDoc.Identity server) { + checkState(server_id == null); + return new ReportSearchRequest(server._id(), null, null, page, perPage); + } + + public ReportSearchRequest forFamilies(Collection familyIds) { + checkState(family_ids == null); + return new ReportSearchRequest(null, familyIds, null, page, perPage); + } + + public ReportSearchRequest forPlayer(PlayerId playerId) { + checkState(user_id == null); + return new ReportSearchRequest(server_id, family_ids, playerId._id(), page, perPage); + } + + @Override + public Integer skip() { + return (page - 1) * perPage; + } + + @Override + public Integer limit() { + return perPage; + } +} diff --git a/API/api/src/main/java/tc/oc/api/serialization/DurationTypeAdapter.java b/API/api/src/main/java/tc/oc/api/serialization/DurationTypeAdapter.java new file mode 100644 index 0000000..8a3ad1f --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/serialization/DurationTypeAdapter.java @@ -0,0 +1,20 @@ +package tc.oc.api.serialization; + +import java.io.IOException; + +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; +import java.time.Duration; + +public class DurationTypeAdapter extends NullableTypeAdapter { + + @Override + public void writeNonNull(JsonWriter out, Duration duration) throws IOException { + out.value(duration.toMillis()); + } + + @Override + public Duration readNonNull(JsonReader in) throws IOException { + return Duration.ofMillis(in.nextLong()); + } +} diff --git a/API/api/src/main/java/tc/oc/api/serialization/GsonBinder.java b/API/api/src/main/java/tc/oc/api/serialization/GsonBinder.java new file mode 100644 index 0000000..811b868 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/serialization/GsonBinder.java @@ -0,0 +1,66 @@ +package tc.oc.api.serialization; + +import java.lang.reflect.Type; + +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonSerializer; +import com.google.gson.TypeAdapter; +import com.google.gson.TypeAdapterFactory; +import com.google.inject.Binder; +import com.google.inject.TypeLiteral; +import com.google.inject.binder.LinkedBindingBuilder; +import com.google.inject.multibindings.MapBinder; +import com.google.inject.multibindings.Multibinder; + +public class GsonBinder { + + private final Multibinder factories; + private final MapBinder adapters; + private final MapBinder hiearchyAdapters; + + public GsonBinder(Binder binder) { + factories = Multibinder.newSetBinder(binder, TypeAdapterFactory.class); + adapters = MapBinder.newMapBinder(binder, Type.class, Object.class); + hiearchyAdapters = MapBinder.newMapBinder(binder, Class.class, Object.class); + } + + public LinkedBindingBuilder bindFactory() { + return factories.addBinding(); + } + + public LinkedBindingBuilder> bindAdapter(Class type) { + return (LinkedBindingBuilder) adapters.addBinding(type); + } + + public LinkedBindingBuilder> bindAdapter(TypeLiteral type) { + return (LinkedBindingBuilder) adapters.addBinding(type.getType()); + } + + public LinkedBindingBuilder> bindSerializer(Class type) { + return (LinkedBindingBuilder) adapters.addBinding(type); + } + + public LinkedBindingBuilder> bindSerializer(TypeLiteral type) { + return (LinkedBindingBuilder) adapters.addBinding(type.getType()); + } + + public LinkedBindingBuilder> bindDeserializer(Class type) { + return (LinkedBindingBuilder) adapters.addBinding(type); + } + + public LinkedBindingBuilder> bindDeserializer(TypeLiteral type) { + return (LinkedBindingBuilder) adapters.addBinding(type.getType()); + } + + public LinkedBindingBuilder> bindHiearchyAdapter(Class type) { + return (LinkedBindingBuilder) hiearchyAdapters.addBinding(type); + } + + public LinkedBindingBuilder> bindHiearchySerializer(Class type) { + return (LinkedBindingBuilder) hiearchyAdapters.addBinding(type); + } + + public LinkedBindingBuilder> bindHiearchyDeserializer(Class type) { + return (LinkedBindingBuilder) hiearchyAdapters.addBinding(type); + } +} diff --git a/API/api/src/main/java/tc/oc/api/serialization/InetAddressTypeAdapter.java b/API/api/src/main/java/tc/oc/api/serialization/InetAddressTypeAdapter.java new file mode 100644 index 0000000..914e43a --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/serialization/InetAddressTypeAdapter.java @@ -0,0 +1,19 @@ +package tc.oc.api.serialization; + +import java.io.IOException; +import java.net.InetAddress; + +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; + +public class InetAddressTypeAdapter extends NullableTypeAdapter { + @Override + protected void writeNonNull(JsonWriter out, InetAddress value) throws IOException { + out.value(value.getHostAddress()); + } + + @Override + protected InetAddress readNonNull(JsonReader in) throws IOException { + return InetAddress.getByName(in.nextString()); + } +} diff --git a/API/api/src/main/java/tc/oc/api/serialization/InstantTypeAdapter.java b/API/api/src/main/java/tc/oc/api/serialization/InstantTypeAdapter.java new file mode 100644 index 0000000..f9df120 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/serialization/InstantTypeAdapter.java @@ -0,0 +1,40 @@ +package tc.oc.api.serialization; + +import java.io.IOException; +import java.time.Instant; +import java.util.Date; + +import com.google.gson.Gson; +import com.google.gson.TypeAdapter; +import com.google.gson.TypeAdapterFactory; +import com.google.gson.reflect.TypeToken; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; + +public class InstantTypeAdapter extends NullableTypeAdapter { + public static class Factory implements TypeAdapterFactory { + @Override + public TypeAdapter create(Gson gson, TypeToken type) { + if(Instant.class == type.getRawType()) { + return (TypeAdapter) new InstantTypeAdapter(gson.getAdapter(Date.class)); + } + return null; + } + } + + private final TypeAdapter dateAdapter; + + public InstantTypeAdapter(TypeAdapter dateAdapter) { + this.dateAdapter = dateAdapter; + } + + @Override + protected void writeNonNull(JsonWriter out, Instant value) throws IOException { + dateAdapter.write(out, Date.from(value)); + } + + @Override + protected Instant readNonNull(JsonReader in) throws IOException { + return dateAdapter.read(in).toInstant(); + } +} diff --git a/API/api/src/main/java/tc/oc/api/serialization/JsonDebugReader.java b/API/api/src/main/java/tc/oc/api/serialization/JsonDebugReader.java new file mode 100644 index 0000000..d541047 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/serialization/JsonDebugReader.java @@ -0,0 +1,133 @@ +package tc.oc.api.serialization; + +import java.io.IOException; +import java.io.Reader; +import java.util.Collection; +import java.util.Stack; + +import com.google.api.client.repackaged.com.google.common.base.Joiner; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; + +public class JsonDebugReader extends JsonReader { + + public static abstract class Context {} + + public static class ArrayContext extends Context { + int index = -1; + + @Override public String toString() { + return "[" + index + "]"; + } + } + + public static class ObjectContext extends Context { + String key = "(no key)"; + + @Override public String toString() { + return "." + key; + } + } + + private final Stack context = new Stack<>(); + + /** + * Creates a new instance that reads a JSON-encoded stream from {@code in}. + * + * @param reader + */ + public JsonDebugReader(Reader reader) { + super(reader); + } + + public Collection getContext() { + return context; + } + + public String getJoinedContext() { + return context.isEmpty() ? "(root)" : Joiner.on("").join(context); + } + + @Override public String toString() { + return getClass().getSimpleName() + " at " + getJoinedContext(); + } + + private void advanceArray() { + if(context.isEmpty()) return; + final Context c = context.lastElement(); + if(c instanceof ArrayContext) { + ((ArrayContext) c).index++; + } + } + + @Override public void beginArray() throws IOException { + super.beginArray(); + context.push(new ArrayContext()); + } + + @Override public void endArray() throws IOException { + context.pop(); + super.endArray(); + } + + @Override public void beginObject() throws IOException { + super.beginObject(); + context.push(new ObjectContext()); + } + + @Override public void endObject() throws IOException { + context.pop(); + super.endObject(); + } + + @Override public String nextName() throws IOException { + return ((ObjectContext) context.lastElement()).key = super.nextName(); + } + + @Override public String nextString() throws IOException { + advanceArray(); + return super.nextString(); + } + + @Override public boolean nextBoolean() throws IOException { + advanceArray(); + return super.nextBoolean(); + } + + @Override public void nextNull() throws IOException { + advanceArray(); + super.nextNull(); + } + + @Override public double nextDouble() throws IOException { + advanceArray(); + return super.nextDouble(); + } + + @Override public long nextLong() throws IOException { + advanceArray(); + return super.nextLong(); + } + + @Override public int nextInt() throws IOException { + advanceArray(); + return super.nextInt(); + } + + @Override public void skipValue() throws IOException { + advanceArray(); + super.skipValue(); + } + + @Override public boolean hasNext() throws IOException { + return super.hasNext(); + } + + @Override public JsonToken peek() throws IOException { + return super.peek(); + } + + @Override public void close() throws IOException { + super.close(); + } +} diff --git a/API/api/src/main/java/tc/oc/api/serialization/JsonUtils.java b/API/api/src/main/java/tc/oc/api/serialization/JsonUtils.java new file mode 100644 index 0000000..33b081e --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/serialization/JsonUtils.java @@ -0,0 +1,38 @@ +package tc.oc.api.serialization; + +import java.io.Reader; +import java.io.StringReader; +import java.lang.reflect.Type; +import javax.inject.Inject; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonSyntaxException; + +public class JsonUtils { + private final Gson gson; + private final Gson prettyGson; + + @Inject JsonUtils(Gson gson, @Pretty Gson prettyGson) { + this.gson = gson; + this.prettyGson = prettyGson; + } + + public String errorContext(Reader reader, Type type) { + JsonDebugReader debugReader = new JsonDebugReader(reader); + try { + gson.fromJson(debugReader, type); + } catch(JsonSyntaxException e) { + return debugReader.getJoinedContext(); + } + return "(no parsing error detected)"; + } + + public String errorContext(String json, Type type) { + return errorContext(new StringReader(json), type); + } + + public String prettify(String ugly) { + return prettyGson.toJson(prettyGson.fromJson(ugly, JsonElement.class)); + } +} diff --git a/API/api/src/main/java/tc/oc/api/serialization/LenientEnumSetTypeAdapter.java b/API/api/src/main/java/tc/oc/api/serialization/LenientEnumSetTypeAdapter.java new file mode 100644 index 0000000..c96475e --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/serialization/LenientEnumSetTypeAdapter.java @@ -0,0 +1,49 @@ +package tc.oc.api.serialization; + +import java.io.IOException; +import java.util.EnumSet; +import java.util.Set; +import javax.inject.Inject; + +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; +import com.google.inject.TypeLiteral; + +/** + * Generic {@link Set} adapter that silently skips unrecognized values when reading + */ +public class LenientEnumSetTypeAdapter> extends NullableTypeAdapter> { + + private final Class type; + + @Inject public LenientEnumSetTypeAdapter(TypeLiteral type) { + this.type = (Class) type.getRawType(); + } + + @Override + protected void writeNonNull(JsonWriter out, Set value) throws IOException { + out.beginArray(); + for(T t : value) { + out.value(t.name()); + } + out.endArray(); + } + + @Override + protected Set readNonNull(JsonReader in) throws IOException { + final EnumSet set = EnumSet.noneOf(type); + in.beginArray(); + while(in.hasNext()) { + final String name = in.nextString(); + final T element; + try { + element = Enum.valueOf(type, name); + } catch(IllegalArgumentException e) { + continue; + } + set.add(element); + } + in.endArray(); + return set; + } +} diff --git a/API/api/src/main/java/tc/oc/api/serialization/NullableTypeAdapter.java b/API/api/src/main/java/tc/oc/api/serialization/NullableTypeAdapter.java new file mode 100644 index 0000000..2371302 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/serialization/NullableTypeAdapter.java @@ -0,0 +1,38 @@ +package tc.oc.api.serialization; + +import java.io.IOException; + +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.google.gson.stream.JsonWriter; + +/** + * {@link TypeAdapter} base that handles null values in the typical fashion. + * Gson has {@link #nullSafe()} for this purpose, but it seems safer to + * make it part of the adapter itself rather than assuming it will be wrapped. + */ +public abstract class NullableTypeAdapter extends TypeAdapter { + + protected abstract void writeNonNull(JsonWriter out, T value) throws IOException; + + protected abstract T readNonNull(JsonReader in) throws IOException; + + @Override + final public void write(JsonWriter out, T value) throws IOException { + if (value == null) { + out.nullValue(); + } else { + writeNonNull(out, value); + } + } + + @Override + final public T read(JsonReader reader) throws IOException { + if (reader.peek() == JsonToken.NULL) { + reader.nextNull(); + return null; + } + return readNonNull(reader); + } +} diff --git a/API/api/src/main/java/tc/oc/api/serialization/PathTypeAdapter.java b/API/api/src/main/java/tc/oc/api/serialization/PathTypeAdapter.java new file mode 100644 index 0000000..f1d1fde --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/serialization/PathTypeAdapter.java @@ -0,0 +1,21 @@ +package tc.oc.api.serialization; + +import java.io.IOException; +import java.nio.file.Path; +import java.nio.file.Paths; + +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; + +public class PathTypeAdapter extends TypeAdapter { + @Override + public void write(JsonWriter out, Path value) throws IOException { + out.value(value.toString()); + } + + @Override + public Path read(JsonReader in) throws IOException { + return Paths.get(in.nextString()); + } +} diff --git a/API/api/src/main/java/tc/oc/api/serialization/PlayerIdTypeAdapter.java b/API/api/src/main/java/tc/oc/api/serialization/PlayerIdTypeAdapter.java new file mode 100644 index 0000000..1e07d04 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/serialization/PlayerIdTypeAdapter.java @@ -0,0 +1,33 @@ +package tc.oc.api.serialization; + +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; +import tc.oc.api.docs.SimplePlayerId; +import tc.oc.api.docs.PlayerId; + +import java.io.IOException; + +public class PlayerIdTypeAdapter extends TypeAdapter { + + @Override + public void write(JsonWriter out, PlayerId value) throws IOException { + out + .beginArray() + .value(value.player_id()) + .value(value.username()) + .value(value._id()) + .endArray(); + } + + @Override + public PlayerId read(JsonReader in) throws IOException { + in.beginArray(); + String player_id = in.nextString(); + String username = in.nextString(); + String _id = in.nextString(); + in.endArray(); + + return new SimplePlayerId(_id, player_id, username); + } +} diff --git a/API/api/src/main/java/tc/oc/api/serialization/Pretty.java b/API/api/src/main/java/tc/oc/api/serialization/Pretty.java new file mode 100644 index 0000000..f5a0b4b --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/serialization/Pretty.java @@ -0,0 +1,16 @@ +package tc.oc.api.serialization; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import javax.inject.Qualifier; + +import com.google.gson.Gson; +import com.google.inject.BindingAnnotation; + +/** + * Qualify a {@link Gson} binding with this to get the pretty-printing version + */ +@Qualifier +@BindingAnnotation +@Retention(RetentionPolicy.RUNTIME) +public @interface Pretty {} diff --git a/API/api/src/main/java/tc/oc/api/serialization/SemanticVersionTypeAdapter.java b/API/api/src/main/java/tc/oc/api/serialization/SemanticVersionTypeAdapter.java new file mode 100644 index 0000000..f8b9f3e --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/serialization/SemanticVersionTypeAdapter.java @@ -0,0 +1,30 @@ +package tc.oc.api.serialization; + +import java.io.IOException; + +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonToken; +import com.google.gson.stream.JsonWriter; +import tc.oc.api.docs.SemanticVersion; + +public class SemanticVersionTypeAdapter extends TypeAdapter { + @Override + public void write(JsonWriter out, SemanticVersion version) throws IOException { + out.beginArray(); + out.value(version.major()); + out.value(version.minor()); + if(version.patch() != 0) out.value(version.patch()); + out.endArray(); + } + + @Override + public SemanticVersion read(JsonReader in) throws IOException { + in.beginArray(); + int major = in.nextInt(); + int minor = in.nextInt(); + int patch = in.peek() == JsonToken.END_ARRAY ? 0 : in.nextInt(); + in.endArray(); + return new SemanticVersion(major, minor, patch); + } +} diff --git a/API/api/src/main/java/tc/oc/api/serialization/SerializationManifest.java b/API/api/src/main/java/tc/oc/api/serialization/SerializationManifest.java new file mode 100644 index 0000000..38b6978 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/serialization/SerializationManifest.java @@ -0,0 +1,46 @@ +package tc.oc.api.serialization; + +import java.lang.reflect.Type; +import java.util.Map; +import java.util.Set; +import javax.inject.Singleton; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.TypeAdapterFactory; +import com.google.inject.Provides; +import tc.oc.commons.core.inject.Manifest; + +public class SerializationManifest extends Manifest { + + private static final String ISO8601_DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ssX"; + + @Override + protected void configure() { + install(new TypeAdaptersManifest()); + } + + @Provides + GsonBuilder gsonBuilder(Set factories, Map adapters, Map hiearchyAdapters) { + GsonBuilder builder = new GsonBuilder() + .setDateFormat(ISO8601_DATE_FORMAT) + .serializeSpecialFloatingPointValues() // Infinity and NaN + .serializeNulls(); // Needed so we can clear fields in PartialModel document updates + + factories.forEach(builder::registerTypeAdapterFactory); + adapters.forEach(builder::registerTypeAdapter); + hiearchyAdapters.forEach(builder::registerTypeHierarchyAdapter); + + return builder; + } + + @Provides @Singleton + Gson gson(GsonBuilder builder) { + return builder.create(); + } + + @Provides @Singleton @Pretty + Gson prettyGson(GsonBuilder builder) { + return builder.setPrettyPrinting().create(); + } +} diff --git a/API/api/src/main/java/tc/oc/api/serialization/StrictEnumTypeAdapter.java b/API/api/src/main/java/tc/oc/api/serialization/StrictEnumTypeAdapter.java new file mode 100644 index 0000000..9babf7e --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/serialization/StrictEnumTypeAdapter.java @@ -0,0 +1,50 @@ +package tc.oc.api.serialization; + +import java.io.IOException; + +import com.google.gson.Gson; +import com.google.gson.TypeAdapter; +import com.google.gson.TypeAdapterFactory; +import com.google.gson.reflect.TypeToken; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; + +/** + * Enum serializer that throws an exception when deserializing an unrecognized value. + * (Gson's built-in adapter silently converts it to null) + * + * NOTE: Does not handle @SerializedName + */ +public class StrictEnumTypeAdapter> extends NullableTypeAdapter { + + private final Class type; + + public StrictEnumTypeAdapter(Class type) { + this.type = type; + } + + @Override + protected void writeNonNull(JsonWriter out, T value) throws IOException { + out.value(value.name()); + } + + @Override + protected T readNonNull(JsonReader in) throws IOException { + final String value = in.nextString(); + try { + return Enum.valueOf(type, value); + } catch(IllegalArgumentException e) { + throw new IOException("Unrecognized value '" + value + "' for enum " + type.getName(), e); + } + } + + public static class Factory implements TypeAdapterFactory { + @Override + public TypeAdapter create(Gson gson, TypeToken type) { + if(Enum.class.isAssignableFrom(type.getRawType())) { + return (TypeAdapter) new StrictEnumTypeAdapter(type.getRawType()); + } + return null; + } + } +} diff --git a/API/api/src/main/java/tc/oc/api/serialization/TypeAdaptersManifest.java b/API/api/src/main/java/tc/oc/api/serialization/TypeAdaptersManifest.java new file mode 100644 index 0000000..6418b96 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/serialization/TypeAdaptersManifest.java @@ -0,0 +1,39 @@ +package tc.oc.api.serialization; + +import java.net.InetAddress; +import java.nio.file.Path; +import java.time.Duration; +import java.util.Set; +import java.util.UUID; + +import com.google.inject.TypeLiteral; +import tc.oc.api.docs.PlayerId; +import tc.oc.api.docs.SemanticVersion; +import tc.oc.api.docs.UserId; +import tc.oc.api.docs.virtual.MapDoc; +import tc.oc.api.docs.virtual.MatchDoc; +import tc.oc.commons.core.inject.Manifest; + +public class TypeAdaptersManifest extends Manifest { + + @Override + protected void configure() { + final GsonBinder gson = new GsonBinder(binder()); + + gson.bindFactory().to(StrictEnumTypeAdapter.Factory.class); + gson.bindFactory().to(InstantTypeAdapter.Factory.class); + + gson.bindAdapter(UUID.class).to(UuidTypeAdapter.class); + gson.bindAdapter(UserId.class).to(UserIdTypeAdapter.class); + gson.bindAdapter(PlayerId.class).to(PlayerIdTypeAdapter.class); + gson.bindAdapter(Duration.class).to(DurationTypeAdapter.class); + gson.bindAdapter(SemanticVersion.class).to(SemanticVersionTypeAdapter.class); + gson.bindAdapter(InetAddress.class).to(InetAddressTypeAdapter.class); + gson.bindAdapter(Path.class).to(PathTypeAdapter.class); + + gson.bindAdapter(new TypeLiteral>(){}) + .to(new TypeLiteral>(){}); + gson.bindAdapter(new TypeLiteral>(){}) + .to(new TypeLiteral>(){}); + } +} diff --git a/API/api/src/main/java/tc/oc/api/serialization/UserIdTypeAdapter.java b/API/api/src/main/java/tc/oc/api/serialization/UserIdTypeAdapter.java new file mode 100644 index 0000000..67ccc26 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/serialization/UserIdTypeAdapter.java @@ -0,0 +1,22 @@ +package tc.oc.api.serialization; + +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; +import tc.oc.api.docs.SimpleUserId; +import tc.oc.api.docs.UserId; + +import java.io.IOException; + +public class UserIdTypeAdapter extends TypeAdapter { + + @Override + public void write(JsonWriter out, UserId value) throws IOException { + out.value(value.toString()); + } + + @Override + public UserId read(JsonReader in) throws IOException { + return new SimpleUserId(in.nextString()); + } +} diff --git a/API/api/src/main/java/tc/oc/api/serialization/UuidTypeAdapter.java b/API/api/src/main/java/tc/oc/api/serialization/UuidTypeAdapter.java new file mode 100644 index 0000000..21aceb2 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/serialization/UuidTypeAdapter.java @@ -0,0 +1,20 @@ +package tc.oc.api.serialization; + +import java.io.IOException; +import java.util.UUID; + +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; +import tc.oc.api.util.UUIDs; + +public class UuidTypeAdapter extends NullableTypeAdapter { + @Override + protected void writeNonNull(JsonWriter out, UUID value) throws IOException { + out.value(UUIDs.normalize(value)); + } + + @Override + protected UUID readNonNull(JsonReader in) throws IOException { + return UUIDs.parse(in.nextString()); + } +} diff --git a/API/api/src/main/java/tc/oc/api/servers/BungeeMetricRequest.java b/API/api/src/main/java/tc/oc/api/servers/BungeeMetricRequest.java new file mode 100644 index 0000000..44b5aba --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/servers/BungeeMetricRequest.java @@ -0,0 +1,16 @@ +package tc.oc.api.servers; + +public class BungeeMetricRequest { + public String ip; + public Type type; + + public BungeeMetricRequest(String ip, Type type) { + this.ip = ip; + this.type = type; + } + + public enum Type { + PING, + LOGIN, + } +} diff --git a/API/api/src/main/java/tc/oc/api/servers/NullServerService.java b/API/api/src/main/java/tc/oc/api/servers/NullServerService.java new file mode 100644 index 0000000..fa6e11a --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/servers/NullServerService.java @@ -0,0 +1,15 @@ +package tc.oc.api.servers; + +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import tc.oc.api.docs.Server; +import tc.oc.api.docs.virtual.ServerDoc; +import tc.oc.api.model.NullModelService; + +public class NullServerService extends NullModelService implements ServerService { + + @Override + public ListenableFuture doBungeeMetric(BungeeMetricRequest request) { + return Futures.immediateFuture(null); + } +} diff --git a/API/api/src/main/java/tc/oc/api/servers/PingRequest.java b/API/api/src/main/java/tc/oc/api/servers/PingRequest.java new file mode 100644 index 0000000..7ddfedb --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/servers/PingRequest.java @@ -0,0 +1,5 @@ +package tc.oc.api.servers; + +public class PingRequest { + public String vhost; +} diff --git a/API/api/src/main/java/tc/oc/api/servers/PingResult.java b/API/api/src/main/java/tc/oc/api/servers/PingResult.java new file mode 100644 index 0000000..4f19cbe --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/servers/PingResult.java @@ -0,0 +1,7 @@ +package tc.oc.api.servers; + +public class PingResult { + public int players; + public int slots; + public String motd; +} diff --git a/API/api/src/main/java/tc/oc/api/servers/ServerModelManifest.java b/API/api/src/main/java/tc/oc/api/servers/ServerModelManifest.java new file mode 100644 index 0000000..c080968 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/servers/ServerModelManifest.java @@ -0,0 +1,21 @@ +package tc.oc.api.servers; + +import com.google.inject.multibindings.OptionalBinder; +import tc.oc.api.docs.Server; +import tc.oc.api.docs.virtual.ServerDoc; +import tc.oc.api.model.ModelBinders; +import tc.oc.commons.core.inject.HybridManifest; + +public class ServerModelManifest extends HybridManifest implements ModelBinders { + @Override + protected void configure() { + bindAndExpose(ServerStore.class); + + bindModel(Server.class, ServerDoc.Partial.class, model -> { + model.bindStore().to(ServerStore.class); + model.bindService().to(ServerService.class); + }); + + OptionalBinder.newOptionalBinder(publicBinder(), ServerService.class); + } +} diff --git a/API/api/src/main/java/tc/oc/api/servers/ServerSearchRequest.java b/API/api/src/main/java/tc/oc/api/servers/ServerSearchRequest.java new file mode 100644 index 0000000..9c67dc0 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/servers/ServerSearchRequest.java @@ -0,0 +1,11 @@ +package tc.oc.api.servers; + +import tc.oc.api.annotations.Serialize; +import tc.oc.api.docs.Server; +import tc.oc.api.message.types.FindRequest; + +public class ServerSearchRequest extends FindRequest { + + @Serialize private final boolean offline = true; + @Serialize private final boolean unlisted = true; +} diff --git a/API/api/src/main/java/tc/oc/api/servers/ServerService.java b/API/api/src/main/java/tc/oc/api/servers/ServerService.java new file mode 100644 index 0000000..d844c89 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/servers/ServerService.java @@ -0,0 +1,11 @@ +package tc.oc.api.servers; + +import com.google.common.util.concurrent.ListenableFuture; +import tc.oc.api.docs.Server; +import tc.oc.api.docs.virtual.ServerDoc; +import tc.oc.api.model.ModelService; + +public interface ServerService extends ModelService { + + ListenableFuture doBungeeMetric(BungeeMetricRequest request); +} diff --git a/API/api/src/main/java/tc/oc/api/servers/ServerStore.java b/API/api/src/main/java/tc/oc/api/servers/ServerStore.java new file mode 100644 index 0000000..3105800 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/servers/ServerStore.java @@ -0,0 +1,77 @@ +package tc.oc.api.servers; + +import java.util.HashMap; +import java.util.Map; +import javax.annotation.Nullable; +import javax.inject.Singleton; + +import com.google.common.collect.HashMultimap; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.SetMultimap; +import tc.oc.api.docs.Arena; +import tc.oc.api.docs.Server; +import tc.oc.api.docs.virtual.ServerDoc; +import tc.oc.api.message.types.FindRequest; +import tc.oc.api.model.ModelStore; +import tc.oc.commons.core.util.Nullables; + +import static com.google.common.base.Preconditions.checkArgument; + +/** + * Maintains a local cache of all server documents, in real time over AMQP + */ +@Singleton +public class ServerStore extends ModelStore { + + private final Map byBungeeName = new HashMap<>(); + private final SetMultimap byRole = HashMultimap.create(); + private final SetMultimap byArenaId = HashMultimap.create(); + + @Override + protected FindRequest refreshAllRequest() { + return new ServerSearchRequest(); + } + + public @Nullable Server tryBungeeName(String name) { + checkArgument(!"default".equals(name), "Cannot lookup lobbies by bungee_name"); + return byBungeeName.get(name); + } + + public Server byBungeeName(String name) { + return Nullables.orElseThrow( + tryBungeeName(name), + () -> new IllegalStateException("Missing server with bungee_name '" + name + "'") + ); + } + + public ImmutableSet byArena(Arena arena) { + return ImmutableSet.copyOf(byArenaId.get(arena._id())); + } + + public int countBukkitPlayers() { + int playerCount = 0; + for(Server server : byRole.get(ServerDoc.Role.PGM)) { + if(server.online()) playerCount += server.num_online(); + } + for(Server server : byRole.get(ServerDoc.Role.LOBBY)) { + if(server.online()) playerCount += server.num_online(); + } + return playerCount; + } + + @Override + protected void unindex(Server doc) { + super.unindex(doc); + byRole.remove(doc.role(), doc); + if(doc.arena_id() != null) byArenaId.remove(doc.arena_id(), doc); + if(doc.bungee_name() != null) byBungeeName.remove(doc.bungee_name()); + } + + @Override + protected void reindex(Server doc) { + super.reindex(doc); + byRole.put(doc.role(), doc); + if(doc.arena_id() != null) byArenaId.put(doc.arena_id(), doc); + if(doc.bungee_name() != null) byBungeeName.put(doc.bungee_name(), doc); + } +} diff --git a/API/api/src/main/java/tc/oc/api/sessions/BadNickname.java b/API/api/src/main/java/tc/oc/api/sessions/BadNickname.java new file mode 100644 index 0000000..a47476c --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/sessions/BadNickname.java @@ -0,0 +1,10 @@ +package tc.oc.api.sessions; + +import tc.oc.api.annotations.Serialize; +import tc.oc.api.message.types.Reply; + +@Serialize +public interface BadNickname extends Reply { + enum Problem { TAKEN, INVALID } + Problem problem(); +} diff --git a/API/api/src/main/java/tc/oc/api/sessions/NullSessionService.java b/API/api/src/main/java/tc/oc/api/sessions/NullSessionService.java new file mode 100644 index 0000000..a1409e7 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/sessions/NullSessionService.java @@ -0,0 +1,41 @@ +package tc.oc.api.sessions; + +import java.util.Collections; + +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import tc.oc.api.docs.Session; +import tc.oc.api.docs.UserId; +import tc.oc.api.docs.virtual.ServerDoc; +import tc.oc.api.docs.virtual.SessionDoc; +import tc.oc.api.exceptions.NotFound; +import tc.oc.api.message.types.FindMultiResponse; +import tc.oc.api.model.NullModelService; + +public class NullSessionService extends NullModelService implements SessionService { + + @Override + public ListenableFuture start(SessionStartRequest request) { + return Futures.immediateFailedFuture(new NotFound()); + } + + @Override + public ListenableFuture finish(Session session) { + return Futures.immediateFuture(null); + } + + @Override + public ListenableFuture online(UserId player) { + return Futures.immediateFailedFuture(new NotFound()); + } + + @Override + public ListenableFuture> friends(UserId player) { + return Futures.immediateFuture(Collections::emptyList); + } + + @Override + public ListenableFuture> staff(ServerDoc.Network network, boolean disguised) { + return Futures.immediateFuture(Collections::emptyList); + } +} diff --git a/API/api/src/main/java/tc/oc/api/sessions/SessionChange.java b/API/api/src/main/java/tc/oc/api/sessions/SessionChange.java new file mode 100644 index 0000000..de45b23 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/sessions/SessionChange.java @@ -0,0 +1,13 @@ +package tc.oc.api.sessions; + +import javax.annotation.Nullable; + +import tc.oc.api.annotations.Serialize; +import tc.oc.api.docs.Session; +import tc.oc.api.message.Message; + +@Serialize +public interface SessionChange extends Message { + @Nullable Session old_session(); + @Nullable Session new_session(); +} diff --git a/API/api/src/main/java/tc/oc/api/sessions/SessionModelManifest.java b/API/api/src/main/java/tc/oc/api/sessions/SessionModelManifest.java new file mode 100644 index 0000000..39b8c71 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/sessions/SessionModelManifest.java @@ -0,0 +1,19 @@ +package tc.oc.api.sessions; + +import com.google.inject.multibindings.OptionalBinder; +import tc.oc.api.docs.Session; +import tc.oc.api.docs.virtual.SessionDoc; +import tc.oc.api.model.ModelBinders; +import tc.oc.commons.core.inject.HybridManifest; + +public class SessionModelManifest extends HybridManifest implements ModelBinders { + + @Override + protected void configure() { + bindModel(Session.class, SessionDoc.Partial.class, model -> { + model.bindService().to(SessionService.class); + }); + + OptionalBinder.newOptionalBinder(publicBinder(), SessionService.class); + } +} diff --git a/API/api/src/main/java/tc/oc/api/sessions/SessionService.java b/API/api/src/main/java/tc/oc/api/sessions/SessionService.java new file mode 100644 index 0000000..1334628 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/sessions/SessionService.java @@ -0,0 +1,22 @@ +package tc.oc.api.sessions; + +import com.google.common.util.concurrent.ListenableFuture; +import tc.oc.api.docs.Session; +import tc.oc.api.docs.UserId; +import tc.oc.api.docs.virtual.ServerDoc; +import tc.oc.api.docs.virtual.SessionDoc; +import tc.oc.api.message.types.FindMultiResponse; +import tc.oc.api.model.ModelService; + +public interface SessionService extends ModelService { + + ListenableFuture start(SessionStartRequest request); + + ListenableFuture finish(Session session); + + ListenableFuture online(UserId player); + + ListenableFuture> friends(UserId player); + + ListenableFuture> staff(ServerDoc.Network network, boolean disguised); +} diff --git a/API/api/src/main/java/tc/oc/api/sessions/SessionStartRequest.java b/API/api/src/main/java/tc/oc/api/sessions/SessionStartRequest.java new file mode 100644 index 0000000..408d20c --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/sessions/SessionStartRequest.java @@ -0,0 +1,19 @@ +package tc.oc.api.sessions; + +import java.net.InetAddress; +import javax.annotation.Nullable; + +import tc.oc.api.annotations.Serialize; +import tc.oc.api.docs.virtual.Document; + +@Serialize +public interface SessionStartRequest extends Document { + + String server_id(); + + String player_id(); + + InetAddress ip(); + + @Nullable String previous_session_id(); +} diff --git a/API/api/src/main/java/tc/oc/api/tourney/NullTournamentService.java b/API/api/src/main/java/tc/oc/api/tourney/NullTournamentService.java new file mode 100644 index 0000000..92ab229 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/tourney/NullTournamentService.java @@ -0,0 +1,32 @@ +package tc.oc.api.tourney; + +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import tc.oc.api.docs.Entrant; +import tc.oc.api.docs.PlayerId; +import tc.oc.api.docs.Tournament; +import tc.oc.api.exceptions.NotFound; +import tc.oc.api.model.NullQueryService; + +public class NullTournamentService extends NullQueryService implements TournamentService { + + @Override + public ListenableFuture recordMatch(Tournament tournament, String matchId) { + return Futures.immediateFailedFuture(new NotFound()); + } + + @Override + public ListenableFuture entrant(String tournamentId, String teamId) { + return Futures.immediateFailedFuture(new NotFound()); + } + + @Override + public ListenableFuture entrantByTeamName(String tournamentId, String teamName) { + return Futures.immediateFailedFuture(new NotFound()); + } + + @Override + public ListenableFuture entrantByMember(String tournamentId, PlayerId playerId) { + return Futures.immediateFailedFuture(new NotFound()); + } +} diff --git a/API/api/src/main/java/tc/oc/api/tourney/RecordMatchResponse.java b/API/api/src/main/java/tc/oc/api/tourney/RecordMatchResponse.java new file mode 100644 index 0000000..df43fa5 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/tourney/RecordMatchResponse.java @@ -0,0 +1,15 @@ +package tc.oc.api.tourney; + +import java.util.Set; +import javax.annotation.Nonnull; + +import tc.oc.api.annotations.Serialize; +import tc.oc.api.docs.Entrant; +import tc.oc.api.docs.virtual.Document; +import tc.oc.api.docs.virtual.MatchDoc; + +@Serialize +public interface RecordMatchResponse extends Document { + @Nonnull MatchDoc match(); + @Nonnull Set entrants(); +} diff --git a/API/api/src/main/java/tc/oc/api/tourney/TeamUtils.java b/API/api/src/main/java/tc/oc/api/tourney/TeamUtils.java new file mode 100644 index 0000000..cfd38af --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/tourney/TeamUtils.java @@ -0,0 +1,9 @@ +package tc.oc.api.tourney; + +public final class TeamUtils { + private TeamUtils() {} + + public static String normalizeName(String name) { + return name.toLowerCase().replaceAll("[^a-z0-9]", ""); + } +} diff --git a/API/api/src/main/java/tc/oc/api/tourney/TournamentModelManifest.java b/API/api/src/main/java/tc/oc/api/tourney/TournamentModelManifest.java new file mode 100644 index 0000000..5ca9df0 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/tourney/TournamentModelManifest.java @@ -0,0 +1,25 @@ +package tc.oc.api.tourney; + +import com.google.inject.multibindings.OptionalBinder; +import tc.oc.api.docs.Tournament; +import tc.oc.api.docs.team; +import tc.oc.api.model.ModelBinders; +import tc.oc.commons.core.inject.HybridManifest; + +public class TournamentModelManifest extends HybridManifest implements ModelBinders { + + @Override + protected void configure() { + bindAndExpose(TournamentStore.class); + + bindModel(team.Team.class, team.Partial.class); + + bindModel(Tournament.class, model -> { + model.bindStore().to(TournamentStore.class); + model.queryService().setBinding().to(TournamentService.class); + }); + + OptionalBinder.newOptionalBinder(publicBinder(), TournamentService.class) + .setDefault().to(NullTournamentService.class); + } +} diff --git a/API/api/src/main/java/tc/oc/api/tourney/TournamentService.java b/API/api/src/main/java/tc/oc/api/tourney/TournamentService.java new file mode 100644 index 0000000..c72eaed --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/tourney/TournamentService.java @@ -0,0 +1,31 @@ +package tc.oc.api.tourney; + +import com.google.common.util.concurrent.ListenableFuture; +import tc.oc.api.docs.Entrant; +import tc.oc.api.docs.PlayerId; +import tc.oc.api.docs.Tournament; +import tc.oc.api.docs.team; +import tc.oc.api.model.QueryService; + +public interface TournamentService extends QueryService { + + ListenableFuture recordMatch(Tournament tournament, String matchId); + + ListenableFuture entrant(String tournamentId, String teamId); + + default ListenableFuture entrant(Tournament tournament, team.Id team) { + return entrant(tournament._id(), team._id()); + } + + ListenableFuture entrantByTeamName(String tournamentId, String teamName); + + default ListenableFuture entrantByTeamName(Tournament tournament, String teamName) { + return entrantByTeamName(tournament._id(), teamName); + } + + ListenableFuture entrantByMember(String tournamentId, PlayerId playerId); + + default ListenableFuture entrantByMember(Tournament tournament, PlayerId playerId) { + return entrantByMember(tournament._id(), playerId); + } +} diff --git a/API/api/src/main/java/tc/oc/api/tourney/TournamentStore.java b/API/api/src/main/java/tc/oc/api/tourney/TournamentStore.java new file mode 100644 index 0000000..66ba01d --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/tourney/TournamentStore.java @@ -0,0 +1,9 @@ +package tc.oc.api.tourney; + +import javax.inject.Singleton; + +import tc.oc.api.docs.Tournament; +import tc.oc.api.model.ModelStore; + +@Singleton +public class TournamentStore extends ModelStore {} diff --git a/API/api/src/main/java/tc/oc/api/trophies/TrophyModelManifest.java b/API/api/src/main/java/tc/oc/api/trophies/TrophyModelManifest.java new file mode 100644 index 0000000..fab577e --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/trophies/TrophyModelManifest.java @@ -0,0 +1,18 @@ +package tc.oc.api.trophies; + +import tc.oc.api.docs.Trophy; +import tc.oc.api.model.ModelBinders; +import tc.oc.commons.core.inject.HybridManifest; + +public class TrophyModelManifest extends HybridManifest implements ModelBinders { + + @Override + protected void configure() { + bindAndExpose(TrophyStore.class); + + bindModel(Trophy.class, model -> { + model.bindStore().to(TrophyStore.class); + model.queryService().setDefault().to(model.nullQueryService()); + }); + } +} diff --git a/API/api/src/main/java/tc/oc/api/trophies/TrophyStore.java b/API/api/src/main/java/tc/oc/api/trophies/TrophyStore.java new file mode 100644 index 0000000..e60b592 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/trophies/TrophyStore.java @@ -0,0 +1,31 @@ +package tc.oc.api.trophies; + +import tc.oc.api.docs.Trophy; +import tc.oc.api.model.ModelStore; + +import javax.inject.Singleton; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +@Singleton +public class TrophyStore extends ModelStore { + + private final Map byName = new HashMap<>(); + + @Override + protected void reindex(Trophy doc) { + super.reindex(doc); + byName.put(doc.name(), doc); + } + + @Override + protected void unindex(Trophy doc) { + super.unindex(doc); + byName.remove(doc.name()); + } + + public Optional byName(String name) { + return Optional.ofNullable(byName.get(name)); + } +} diff --git a/API/api/src/main/java/tc/oc/api/users/ChangeClassRequest.java b/API/api/src/main/java/tc/oc/api/users/ChangeClassRequest.java new file mode 100644 index 0000000..afb31b1 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/users/ChangeClassRequest.java @@ -0,0 +1,13 @@ +package tc.oc.api.users; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import tc.oc.api.annotations.Serialize; +import tc.oc.api.docs.virtual.Document; + +@Serialize +public interface ChangeClassRequest extends Document { + @Nonnull String category(); + @Nullable String name(); +} diff --git a/API/api/src/main/java/tc/oc/api/users/ChangeSettingRequest.java b/API/api/src/main/java/tc/oc/api/users/ChangeSettingRequest.java new file mode 100644 index 0000000..59d962f --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/users/ChangeSettingRequest.java @@ -0,0 +1,14 @@ +package tc.oc.api.users; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +import tc.oc.api.annotations.Serialize; +import tc.oc.api.docs.virtual.Document; + +@Serialize +public interface ChangeSettingRequest extends Document { + @Nonnull String profile(); + @Nonnull String setting(); + @Nullable String value(); +} diff --git a/API/api/src/main/java/tc/oc/api/users/CreditRaindropsRequest.java b/API/api/src/main/java/tc/oc/api/users/CreditRaindropsRequest.java new file mode 100644 index 0000000..e08578e --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/users/CreditRaindropsRequest.java @@ -0,0 +1,9 @@ +package tc.oc.api.users; + +import tc.oc.api.annotations.Serialize; +import tc.oc.api.docs.virtual.Document; + +@Serialize +public interface CreditRaindropsRequest extends Document { + int raindrops(); +} diff --git a/API/api/src/main/java/tc/oc/api/users/LoginRequest.java b/API/api/src/main/java/tc/oc/api/users/LoginRequest.java new file mode 100644 index 0000000..dcf94bf --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/users/LoginRequest.java @@ -0,0 +1,36 @@ +package tc.oc.api.users; + +import tc.oc.api.docs.Server; + +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.util.UUID; +import javax.annotation.Nullable; + +public class LoginRequest { + public final String username; + public final @Nullable UUID uuid; + public final InetAddress ip; + public final String server_id; + public final String virtual_host; + public final boolean start_session; + public final @Nullable String mc_client_version; + + public LoginRequest(String username, @Nullable UUID uuid, InetAddress ip, Server server, boolean start_session) { + this(username, uuid, ip, server, null, start_session); + } + + public LoginRequest(String username, @Nullable UUID uuid, InetAddress ip, Server server, InetSocketAddress virtual_host, boolean start_session) { + this(username, uuid, ip, server, virtual_host, start_session, null); + } + + public LoginRequest(String username, @Nullable UUID uuid, InetAddress ip, Server server, InetSocketAddress virtual_host, boolean start_session, @Nullable String mc_client_version) { + this.username = username; + this.uuid = uuid; + this.ip = ip; + this.server_id = server._id(); + this.virtual_host = virtual_host == null ? null : virtual_host.getHostName(); + this.start_session = start_session; + this.mc_client_version = mc_client_version; + } +} diff --git a/API/api/src/main/java/tc/oc/api/users/LoginResponse.java b/API/api/src/main/java/tc/oc/api/users/LoginResponse.java new file mode 100644 index 0000000..0e35e3f --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/users/LoginResponse.java @@ -0,0 +1,25 @@ +package tc.oc.api.users; + +import java.util.List; +import javax.annotation.Nullable; + +import tc.oc.api.annotations.Serialize; +import tc.oc.api.docs.Punishment; +import tc.oc.api.docs.Session; +import tc.oc.api.docs.User; +import tc.oc.api.docs.Whisper; +import tc.oc.api.docs.virtual.Document; + +@Serialize +public interface LoginResponse extends Document { + + @Nullable String kick(); + @Nullable String message(); + @Nullable String route_to_server(); + + User user(); + @Nullable Session session(); + @Nullable Punishment punishment(); + List whispers(); + int unread_appeal_count(); +} diff --git a/API/api/src/main/java/tc/oc/api/users/LogoutRequest.java b/API/api/src/main/java/tc/oc/api/users/LogoutRequest.java new file mode 100644 index 0000000..5a2d0a2 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/users/LogoutRequest.java @@ -0,0 +1,14 @@ +package tc.oc.api.users; + +import tc.oc.api.docs.UserId; +import tc.oc.api.docs.Server; + +public class LogoutRequest { + public final String player_id; + public final String server_id; + + public LogoutRequest(UserId player, Server server) { + this.player_id = player.player_id(); + this.server_id = server._id(); + } +} diff --git a/API/api/src/main/java/tc/oc/api/users/NullUserService.java b/API/api/src/main/java/tc/oc/api/users/NullUserService.java new file mode 100644 index 0000000..f25e10c --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/users/NullUserService.java @@ -0,0 +1,57 @@ +package tc.oc.api.users; + +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import tc.oc.api.docs.User; +import tc.oc.api.docs.UserId; +import tc.oc.api.docs.virtual.UserDoc; +import tc.oc.api.exceptions.NotFound; +import tc.oc.api.model.NullModelService; + +public class NullUserService extends NullModelService implements UserService { + + @Override + public ListenableFuture find(UserId userId) { + return Futures.immediateFailedFuture(new NotFound()); + } + + @Override + public ListenableFuture search(UserSearchRequest request) { + return Futures.immediateFailedFuture(new NotFound()); + } + + @Override + public ListenableFuture login(LoginRequest request) { + return Futures.immediateFailedFuture(new NotFound()); + } + + @Override + public ListenableFuture logout(LogoutRequest request) { + return Futures.immediateFuture(null); + } + + @Override + public ListenableFuture creditRaindrops(UserId userId, CreditRaindropsRequest request) { + return Futures.immediateFuture(UserUpdateResponse.FAILURE); + } + + @Override + public ListenableFuture purchaseGizmo(UserId userId, PurchaseGizmoRequest request) { + return Futures.immediateFailedFuture(new NotFound()); + } + + @Override + public ListenableFuture update(UserId userId, T update) { + return Futures.immediateFailedFuture(new NotFound()); + } + + @Override + public ListenableFuture changeSetting(UserId userId, ChangeSettingRequest request) { + return Futures.immediateFailedFuture(new NotFound()); + } + + @Override + public ListenableFuture changeClass(UserId userId, ChangeClassRequest request) { + return Futures.immediateFailedFuture(new NotFound()); + } +} diff --git a/API/api/src/main/java/tc/oc/api/users/PurchaseGizmoRequest.java b/API/api/src/main/java/tc/oc/api/users/PurchaseGizmoRequest.java new file mode 100644 index 0000000..ce474d1 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/users/PurchaseGizmoRequest.java @@ -0,0 +1,11 @@ +package tc.oc.api.users; + +public class PurchaseGizmoRequest { + public final String gizmo_name; + public final int price; + + public PurchaseGizmoRequest(String gizmo_name, int price) { + this.gizmo_name = gizmo_name; + this.price = price; + } +} diff --git a/API/api/src/main/java/tc/oc/api/users/UserModelManifest.java b/API/api/src/main/java/tc/oc/api/users/UserModelManifest.java new file mode 100644 index 0000000..522b46b --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/users/UserModelManifest.java @@ -0,0 +1,19 @@ +package tc.oc.api.users; + +import com.google.inject.multibindings.OptionalBinder; +import tc.oc.api.docs.User; +import tc.oc.api.docs.virtual.UserDoc; +import tc.oc.api.model.ModelBinders; +import tc.oc.commons.core.inject.HybridManifest; + +public class UserModelManifest extends HybridManifest implements ModelBinders { + + @Override + protected void configure() { + bindModel(User.class, UserDoc.Partial.class, model -> { + model.bindService().to(UserService.class); + }); + + OptionalBinder.newOptionalBinder(publicBinder(), UserService.class); + } +} diff --git a/API/api/src/main/java/tc/oc/api/users/UserSearchRequest.java b/API/api/src/main/java/tc/oc/api/users/UserSearchRequest.java new file mode 100644 index 0000000..32433b5 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/users/UserSearchRequest.java @@ -0,0 +1,17 @@ +package tc.oc.api.users; + +import javax.annotation.Nullable; + +import tc.oc.api.annotations.Serialize; +import tc.oc.api.docs.PlayerId; +import tc.oc.api.docs.virtual.Document; + +public class UserSearchRequest implements Document { + @Serialize public final String username; + @Serialize public final @Nullable String sender_id; + + public UserSearchRequest(String username, @Nullable PlayerId sender) { + this.username = UserUtils.sanitizeUsername(username); + this.sender_id = sender == null ? null : sender._id(); + } +} diff --git a/API/api/src/main/java/tc/oc/api/users/UserSearchResponse.java b/API/api/src/main/java/tc/oc/api/users/UserSearchResponse.java new file mode 100644 index 0000000..3c47b7e --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/users/UserSearchResponse.java @@ -0,0 +1,27 @@ +package tc.oc.api.users; + +import javax.annotation.Nullable; + +import tc.oc.api.annotations.Serialize; +import tc.oc.api.docs.Server; +import tc.oc.api.docs.Session; +import tc.oc.api.docs.User; +import tc.oc.api.docs.virtual.Document; + +public class UserSearchResponse implements Document { + @Serialize public User user; + @Serialize public boolean online; + @Serialize public boolean disguised; + @Serialize public @Nullable Session last_session; + @Serialize public @Nullable Server last_server; + + public UserSearchResponse() {} + + public UserSearchResponse(User user, boolean online, boolean disguised, @Nullable Session last_session, @Nullable Server last_server) { + this.user = user; + this.online = online; + this.disguised = disguised; + this.last_session = last_session; + this.last_server = last_server; + } +} diff --git a/API/api/src/main/java/tc/oc/api/users/UserService.java b/API/api/src/main/java/tc/oc/api/users/UserService.java new file mode 100644 index 0000000..3d50ac2 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/users/UserService.java @@ -0,0 +1,37 @@ +package tc.oc.api.users; + +import java.util.UUID; + +import com.google.common.util.concurrent.ListenableFuture; +import tc.oc.api.docs.User; +import tc.oc.api.docs.UserId; +import tc.oc.api.docs.virtual.ServerDoc; +import tc.oc.api.docs.virtual.UserDoc; +import tc.oc.api.model.ModelService; + +public interface UserService extends ModelService { + + ListenableFuture find(UserId userId); + + ListenableFuture search(UserSearchRequest request); + + ListenableFuture login(LoginRequest request); + + ListenableFuture logout(LogoutRequest request); + + default ListenableFuture creditRaindrops(UserId userId, int raindrops) { + return creditRaindrops(userId, () -> raindrops); + } + + ListenableFuture creditRaindrops(UserId userId, CreditRaindropsRequest request); + + ListenableFuture purchaseGizmo(UserId userId, PurchaseGizmoRequest request); + + ListenableFuture update(UserId userId, T update); + + ListenableFuture changeSetting(UserId userId, ChangeSettingRequest request); + + ListenableFuture changeClass(UserId userId, ChangeClassRequest request); + + default void requestTeleport(UUID travelerId, ServerDoc.Identity targetServer, UUID targetId) {} +} diff --git a/API/api/src/main/java/tc/oc/api/users/UserUpdateResponse.java b/API/api/src/main/java/tc/oc/api/users/UserUpdateResponse.java new file mode 100644 index 0000000..767ef04 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/users/UserUpdateResponse.java @@ -0,0 +1,26 @@ +package tc.oc.api.users; + +import javax.annotation.Nullable; + +import tc.oc.api.annotations.Serialize; +import tc.oc.api.docs.User; +import tc.oc.api.docs.virtual.Document; + +@Serialize +public interface UserUpdateResponse extends Document { + + boolean success(); + @Nullable User user(); + + UserUpdateResponse FAILURE = new UserUpdateResponse() { + @Override + public boolean success() { + return false; + } + + @Override + public @Nullable User user() { + return null; + } + }; +} diff --git a/API/api/src/main/java/tc/oc/api/users/UserUtils.java b/API/api/src/main/java/tc/oc/api/users/UserUtils.java new file mode 100644 index 0000000..5eb708a --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/users/UserUtils.java @@ -0,0 +1,10 @@ +package tc.oc.api.users; + +import tc.oc.commons.core.formatting.StringUtils; + +public interface UserUtils { + + static String sanitizeUsername(String username) { + return StringUtils.truncate(username.replaceAll("[^A-Za-z0-9_]", ""), 16); + } +} diff --git a/API/api/src/main/java/tc/oc/api/util/Permissions.java b/API/api/src/main/java/tc/oc/api/util/Permissions.java new file mode 100644 index 0000000..3b2fd0e --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/util/Permissions.java @@ -0,0 +1,37 @@ +package tc.oc.api.util; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; + +public final class Permissions { + + private Permissions() {} + + public static final String CONSOLE = "ocn.console"; + public static final String LOGIN = "ocn.login"; + public static final String STAFF = "projectares.staff"; + public static final String OBSERVER = "ocn.observer"; + public static final String PARTICIPANT = "ocn.participant"; + public static final String MAPMAKER = "ocn.mapmaker"; + public static final String DEVELOPER = "ocn.developer"; + public static final String MAPDEV = "pgm.mapdev"; + public static final String MAPERRORS = "pgm.maperrors"; + + /** + * Merge the given by-realm permissions into a single set of permissions using the given (ordered) realms + * @param realms Effective realms, in application order (later realms will override earlier ones) + * @param permsByRealm Permissions, grouped by realm + * @return Effective permissions + */ + public static Map mergePermissions(Collection realms, Map> permsByRealm) { + Map effectivePerms = new HashMap<>(); + for(String realm : realms) { + Map perms = permsByRealm.get(realm); + if(perms != null) { + effectivePerms.putAll(perms); + } + } + return effectivePerms; + } +} diff --git a/API/api/src/main/java/tc/oc/api/util/UUIDs.java b/API/api/src/main/java/tc/oc/api/util/UUIDs.java new file mode 100644 index 0000000..7b74b13 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/util/UUIDs.java @@ -0,0 +1,18 @@ +package tc.oc.api.util; + +import java.util.UUID; + +public abstract class UUIDs { + public static String normalize(UUID uuid) { + return uuid == null ? null : uuid.toString().replace("-", ""); + } + + public static UUID parse(String s) { + if(s.length() != 32) throw new IllegalArgumentException("Invalid UUID: " + s); + return UUID.fromString(s.substring(0, 8) + '-' + + s.substring(8, 12) + '-' + + s.substring(12, 16) + '-' + + s.substring(16, 20) + '-' + + s.substring(20, 32)); + } +} diff --git a/API/api/src/main/java/tc/oc/api/whispers/NullWhisperService.java b/API/api/src/main/java/tc/oc/api/whispers/NullWhisperService.java new file mode 100644 index 0000000..6018511 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/whispers/NullWhisperService.java @@ -0,0 +1,17 @@ +package tc.oc.api.whispers; + +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import tc.oc.api.docs.PlayerId; +import tc.oc.api.docs.Whisper; +import tc.oc.api.docs.virtual.WhisperDoc; +import tc.oc.api.exceptions.NotFound; +import tc.oc.api.model.NullModelService; + +public class NullWhisperService extends NullModelService implements WhisperService { + + @Override + public ListenableFuture forReply(PlayerId user) { + return Futures.immediateFailedFuture(new NotFound()); + } +} diff --git a/API/api/src/main/java/tc/oc/api/whispers/WhisperModelManifest.java b/API/api/src/main/java/tc/oc/api/whispers/WhisperModelManifest.java new file mode 100644 index 0000000..7f7aac7 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/whispers/WhisperModelManifest.java @@ -0,0 +1,21 @@ +package tc.oc.api.whispers; + +import com.google.inject.multibindings.OptionalBinder; +import tc.oc.api.docs.Whisper; +import tc.oc.api.docs.virtual.WhisperDoc; +import tc.oc.api.model.ModelBinders; +import tc.oc.commons.core.inject.HybridManifest; + +public class WhisperModelManifest extends HybridManifest implements ModelBinders { + + @Override + protected void configure() { + bindModel(Whisper.class, WhisperDoc.Partial.class, model -> { + model.bindService().to(WhisperService.class); + }); + + OptionalBinder.newOptionalBinder(publicBinder(), WhisperService.class) + .setDefault().to(NullWhisperService.class); + + } +} diff --git a/API/api/src/main/java/tc/oc/api/whispers/WhisperService.java b/API/api/src/main/java/tc/oc/api/whispers/WhisperService.java new file mode 100644 index 0000000..da92593 --- /dev/null +++ b/API/api/src/main/java/tc/oc/api/whispers/WhisperService.java @@ -0,0 +1,12 @@ +package tc.oc.api.whispers; + +import com.google.common.util.concurrent.ListenableFuture; +import tc.oc.api.docs.PlayerId; +import tc.oc.api.docs.Whisper; +import tc.oc.api.docs.virtual.WhisperDoc; +import tc.oc.api.model.ModelService; + +public interface WhisperService extends ModelService { + + ListenableFuture forReply(PlayerId user); +} diff --git a/API/api/src/main/resources/config.yml b/API/api/src/main/resources/config.yml new file mode 100644 index 0000000..eb5946e --- /dev/null +++ b/API/api/src/main/resources/config.yml @@ -0,0 +1,37 @@ +server: + id: 0123456789abcdef01234567 # _id field from the server document (should be 24 hex digits) + datacenter: DV # datacenter name e.g. "US" + box: pro # local box name + role: LOBBY # ServerDoc.Role value + +# HTTP client settings +api: + http: + base-url: http://localhost:3010 + threads: 0 + connect-timeout: 20000 + read-timeout: 20000 + retries: 10 + +# AMQP client settings +queue: + addresses: + - localhost + username: guest + password: guest + virtual-host: / + connection-timeout: 0 + network-recovery-interval: 5000 + threads: 0 + +# Logging config - you can use this to set the initial level +# of ANY java.util.logging.Logger in the server process. +# Replace '.' with '-' in the logger name. +# +# "log list" command will show all loggers. +# "log level " can be used to change levels in-game. +logging: + root: + level: INFO + tc-oc-api-bukkit-BukkitApi: + level: INFO diff --git a/API/api/src/test/java/tc/oc/ApiTest.java b/API/api/src/test/java/tc/oc/ApiTest.java new file mode 100644 index 0000000..a42d318 --- /dev/null +++ b/API/api/src/test/java/tc/oc/ApiTest.java @@ -0,0 +1,77 @@ +package tc.oc; + +import java.io.InputStream; +import java.time.Instant; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import javax.inject.Inject; +import javax.inject.Named; + +import com.google.gson.Gson; +import com.google.inject.Guice; +import com.google.inject.Provides; +import org.junit.Before; +import tc.oc.api.ApiManifest; +import tc.oc.api.config.ApiConfiguration; +import tc.oc.api.http.HttpClient; +import tc.oc.api.http.HttpManifest; +import tc.oc.api.maps.MapService; +import tc.oc.api.maps.NullMapService; +import tc.oc.api.model.ModelBinders; +import tc.oc.api.model.ModelSync; +import tc.oc.api.queue.QueueClient; +import tc.oc.api.queue.QueueManifest; +import tc.oc.api.servers.NullServerService; +import tc.oc.api.servers.ServerService; +import tc.oc.api.sessions.NullSessionService; +import tc.oc.api.sessions.SessionService; +import tc.oc.api.users.NullUserService; +import tc.oc.api.users.UserService; +import tc.oc.commons.core.inject.HybridManifest; +import tc.oc.commons.core.inject.TestModule; +import tc.oc.inject.ProtectedBinder; + +public abstract class ApiTest { + + protected static final String ISO_DATE = "2000-01-01T00:00:00Z"; + protected static final Instant INSTANT = Instant.parse(ISO_DATE); + + class ApiTestModule extends HybridManifest implements ModelBinders { + @Override + protected void configure() { + install(new TestModule()); + install(new ApiManifest()); + install(new HttpManifest()); + install(new QueueManifest()); + + bind(ExecutorService.class) + .annotatedWith(ModelSync.class) + .toInstance(Executors.newSingleThreadExecutor()); + + bind(ApiConfiguration.class).toInstance(() -> "primary_queue"); + + publicBinder().forOptional(ServerService.class).setBinding().to(NullServerService.class); + publicBinder().forOptional(UserService.class).setBinding().to(NullUserService.class); + publicBinder().forOptional(SessionService.class).setBinding().to(NullSessionService.class); + publicBinder().forOptional(MapService.class).setBinding().to(NullMapService.class); + + requestInjection(ApiTest.this); + } + + @Provides @Named("config.yml") + InputStream configYml() { + return ClassLoader.getSystemResourceAsStream("config.yml"); + } + } + + protected @Inject Gson gson; + protected @Inject QueueClient queueClient; + protected @Inject HttpClient httpClient; + + @Before + public void setUp() { + final ApiTestModule module = new ApiTestModule(); + //ElementPrinter.visit(module); + Guice.createInjector(binder -> ProtectedBinder.newProtectedBinder(binder).install(module)); + } +} diff --git a/API/api/src/test/java/tc/oc/document/ClassDoc.java b/API/api/src/test/java/tc/oc/document/ClassDoc.java new file mode 100644 index 0000000..b2aedae --- /dev/null +++ b/API/api/src/test/java/tc/oc/document/ClassDoc.java @@ -0,0 +1,16 @@ +package tc.oc.document; + +import tc.oc.api.annotations.Serialize; + +@Serialize +public class ClassDoc { + public int number; + public String text; + + public ClassDoc(int number, String text) { + this.number = number; + this.text = text; + } + + public ClassDoc() {} +} diff --git a/API/api/src/test/java/tc/oc/document/DocumentDeserializationTest.java b/API/api/src/test/java/tc/oc/document/DocumentDeserializationTest.java new file mode 100644 index 0000000..70aed95 --- /dev/null +++ b/API/api/src/test/java/tc/oc/document/DocumentDeserializationTest.java @@ -0,0 +1,68 @@ +package tc.oc.document; + +import com.google.gson.JsonObject; +import java.time.Instant; +import org.junit.Test; +import tc.oc.ApiTest; +import tc.oc.api.annotations.Serialize; + +import static org.junit.Assert.*; + +public class DocumentDeserializationTest extends ApiTest { + + JsonObject jsonObject = new JsonObject(); + + void jsonIn(String key, Object value) { + jsonObject.add(key, gson.toJsonTree(value)); + } + + T deserialize(Class type) { + return gson.fromJson(jsonObject, type); + } + + @Test + public void testClassDoc() throws Exception { + jsonIn("number", 123); + jsonIn("text", "abc"); + ClassDoc doc = deserialize(ClassDoc.class); + assertEquals(123, doc.number); + assertEquals("abc", doc.text); + } + + @Test + public void testInterfaceDoc() throws Exception { + jsonIn("number", 123); + jsonIn("text", "abc"); + InterfaceDocImpl doc = deserialize(InterfaceDocImpl.class); + assertEquals(123, doc.number()); + assertEquals("abc", doc.text()); + } + + @Test + public void testGeneratedDoc() throws Exception { + jsonIn("number", 123); + jsonIn("text", "abc"); + InterfaceDoc doc = deserialize(InterfaceDoc.class); + assertEquals(123, doc.number()); + assertEquals("abc", doc.text()); + } + + @Test + public void testGeneratedFieldWithParameterizedTypeWhereTypeParameterHasCustomDeserializer() throws Exception { + jsonIn("instants", new String[] { ISO_DATE }); + GenericFieldInterfaceDoc doc = deserialize(GenericFieldInterfaceDoc.class); + assertTrue("List was deserialized as List, generic type info was lost", + doc.instants().get(0) instanceof Instant); + } +} + +class InterfaceDocImpl implements InterfaceDoc { + private int number; + private String text; + + @Serialize public void number(int n) {number = n; } + @Serialize public void text(String t) { text = t; } + + @Override public int number() { return number; } + @Override public String text() { return text; } +} \ No newline at end of file diff --git a/API/api/src/test/java/tc/oc/document/DocumentGeneratorTest.java b/API/api/src/test/java/tc/oc/document/DocumentGeneratorTest.java new file mode 100644 index 0000000..036a898 --- /dev/null +++ b/API/api/src/test/java/tc/oc/document/DocumentGeneratorTest.java @@ -0,0 +1,127 @@ +package tc.oc.document; + +import java.util.Collections; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import javax.inject.Inject; + +import org.junit.Test; +import tc.oc.ApiTest; +import tc.oc.api.annotations.Serialize; +import tc.oc.api.docs.virtual.BasicDocument; +import tc.oc.api.docs.virtual.Document; +import tc.oc.api.document.DocumentGenerator; +import tc.oc.api.document.DocumentRegistry; + +import static org.junit.Assert.*; +import static tc.oc.test.Assert.*; + +@Serialize interface Empty extends Document {} +@Serialize interface RequiredPrimitive extends Document { int woot(); } +@Serialize interface PrimitiveWithDefault extends Document { default int woot() { return 123; } } +@Serialize interface NullablePrimitive extends Document { @Nullable Integer woot(); } +@Serialize interface NonNullObject extends Document { @Nonnull String woot(); } + +public class DocumentGeneratorTest extends ApiTest { + + @Inject DocumentRegistry registry; + @Inject DocumentGenerator generator; + + @Test + public void testSimpleDocument() throws Exception { + final RequiredPrimitive doc = registry.instantiate(RequiredPrimitive.class, Collections.singletonMap("woot", 123)); + assertEquals(123, doc.woot()); + } + + @Test + public void testMissingPrimitive() throws Exception { + try { + registry.instantiate(RequiredPrimitive.class, Collections.emptyMap()); + fail(); + } catch(IllegalArgumentException e) { + // pass + } + } + + @Test + public void testNullPrimitive() throws Exception { + try { + registry.instantiate(RequiredPrimitive.class, Collections.singletonMap("woot", null)); + fail(); + } catch(NullPointerException e) { + // pass + } + } + + @Test + public void testNonNullObjectWithExplicitNull() throws Exception { + try { + registry.instantiate(NonNullObject.class, Collections.singletonMap("woot", null)); + fail(); + } catch(NullPointerException e) { + // pass + } + } + + @Test + public void testMissingNonNullObject() throws Exception { + try { + registry.instantiate(NonNullObject.class, Collections.emptyMap()); + fail(); + } catch(IllegalArgumentException e) { + // pass + } + } + + @Test + public void testPrimitiveTypeValidation() throws Exception { + try { + registry.instantiate(RequiredPrimitive.class, Collections.singletonMap("woot", "lol")); + fail(); + } catch(ClassCastException e) { + // pass + } + } + + @Test + public void testPrimitiveDefault() throws Exception { + final PrimitiveWithDefault doc = registry.instantiate(PrimitiveWithDefault.class, Collections.emptyMap()); + assertEquals(123, doc.woot()); + } + + @Test + public void testValueForPrimitiveDefault() throws Exception { + final PrimitiveWithDefault doc = registry.instantiate(PrimitiveWithDefault.class, Collections.singletonMap("woot", 456)); + assertEquals(456, doc.woot()); + } + + @Test + public void testPrimitiveDefaultWithExplicitNull() throws Exception { + try { + registry.instantiate(PrimitiveWithDefault.class, Collections.singletonMap("woot", null)); + fail(); + } catch(NullPointerException e) { + // pass + } + } + + @Test + public void testNullablePrimitiveWithExplicitNull() throws Exception { + final NullablePrimitive doc = registry.instantiate(NullablePrimitive.class, Collections.singletonMap("woot", null)); + assertNull(doc.woot()); + } + + @Test + public void testNullablePrimitiveWithImplicitNull() throws Exception { + final NullablePrimitive doc = registry.instantiate(NullablePrimitive.class, Collections.emptyMap()); + assertNull(doc.woot()); + } + + @Test + public void testBaseMethod() throws Exception { + final BasicDocument base = new BasicDocument(); + final int code = generator.instantiate(registry.getMeta(Empty.class), base, Collections.emptyMap()).hashCode(); + assertEquals(base.hashCode(), code); + } +} + diff --git a/API/api/src/test/java/tc/oc/document/DocumentSerializationTest.java b/API/api/src/test/java/tc/oc/document/DocumentSerializationTest.java new file mode 100644 index 0000000..85ba028 --- /dev/null +++ b/API/api/src/test/java/tc/oc/document/DocumentSerializationTest.java @@ -0,0 +1,97 @@ +package tc.oc.document; + +import java.lang.reflect.Type; +import java.util.List; + +import com.google.common.collect.ImmutableList; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import org.junit.Test; +import tc.oc.ApiTest; +import tc.oc.api.annotations.Serialize; +import tc.oc.api.docs.virtual.Document; + +import static org.junit.Assert.*; + +public class DocumentSerializationTest extends ApiTest { + + String jsonText; + JsonElement jsonElement; + JsonObject jsonObject; + + void serialize(Object obj, Type type) { + jsonElement = gson.toJsonTree(obj, type); + jsonText = gson.toJson(jsonElement); + if(jsonElement instanceof JsonObject) jsonObject = (JsonObject) jsonElement; + } + + void serialize(Object obj) { + if(obj != null) serialize(obj, obj.getClass()); + } + + void assertJsonOut(String key, Object value) { + assertEquals(gson.toJsonTree(value), jsonObject.get(key)); + } + + @Test + public void testGsonSerializesAnonymousClasses() throws Exception { + String json = gson.toJson(new Object() { int number = 123; }); + assertEquals("Gson refused to serialize an anonymous class (are you using our custom fork?)", + "{\"number\":123}", json); + } + + @Test + public void testPlain() throws Exception { + serialize(new ClassDoc(123, "abc")); + assertJsonOut("number", 123); + assertJsonOut("text", "abc"); + } + + @Test + public void testAnonymous() throws Exception { + serialize(new Document() { + @Serialize int number = 123; + @Serialize String text = "abc"; + }); + assertJsonOut("number", 123); + assertJsonOut("text", "abc"); + } + + @Test + public void testInherited() throws Exception { + serialize(new InterfaceDoc() { + @Override public int number() { return 123; } + @Override public String text() { return "abc"; } + }); + assertJsonOut("number", 123); + assertJsonOut("text", "abc"); + } + + @Test + public void testParameterized() throws Exception { + serialize(new GenericInterfaceDoc() { + @Override public Integer value() { return 123; } + @Override public List values() { return ImmutableList.of(1, 2, 3); } + }); + assertJsonOut("value", 123); + assertJsonOut("values", new int[]{1, 2, 3}); + } + + @Test + public void testParameterizedComplex() throws Exception { + serialize(new GenericInterfaceDoc>() { + @Override public List value() { + return ImmutableList.of(1, 2, 3); + } + + @Override public List> values() { + return ImmutableList.>of( + ImmutableList.of(1, 2, 3), + ImmutableList.of(4, 5, 6) + ); + } + }); + assertJsonOut("value", new int[]{1, 2, 3}); + assertJsonOut("values", new int[][]{new int[]{1, 2, 3}, new int[]{4, 5, 6}}); + } +} diff --git a/API/api/src/test/java/tc/oc/document/GenericFieldInterfaceDoc.java b/API/api/src/test/java/tc/oc/document/GenericFieldInterfaceDoc.java new file mode 100644 index 0000000..e1e3f4a --- /dev/null +++ b/API/api/src/test/java/tc/oc/document/GenericFieldInterfaceDoc.java @@ -0,0 +1,12 @@ +package tc.oc.document; + +import java.util.List; + +import java.time.Instant; +import tc.oc.api.annotations.Serialize; +import tc.oc.api.docs.virtual.Document; + +@Serialize +public interface GenericFieldInterfaceDoc extends Document { + List instants(); +} diff --git a/API/api/src/test/java/tc/oc/document/GenericInterfaceDoc.java b/API/api/src/test/java/tc/oc/document/GenericInterfaceDoc.java new file mode 100644 index 0000000..8e7a052 --- /dev/null +++ b/API/api/src/test/java/tc/oc/document/GenericInterfaceDoc.java @@ -0,0 +1,12 @@ +package tc.oc.document; + +import java.util.List; + +import tc.oc.api.annotations.Serialize; +import tc.oc.api.docs.virtual.Document; + +@Serialize +public interface GenericInterfaceDoc extends Document { + T value(); + List values(); +} diff --git a/API/api/src/test/java/tc/oc/document/InterfaceDoc.java b/API/api/src/test/java/tc/oc/document/InterfaceDoc.java new file mode 100644 index 0000000..3883b5e --- /dev/null +++ b/API/api/src/test/java/tc/oc/document/InterfaceDoc.java @@ -0,0 +1,10 @@ +package tc.oc.document; + +import tc.oc.api.annotations.Serialize; +import tc.oc.api.docs.virtual.Document; + +@Serialize +public interface InterfaceDoc extends Document { + int number(); + String text(); +} diff --git a/API/api/src/test/java/tc/oc/message/MessageRegistryTest.java b/API/api/src/test/java/tc/oc/message/MessageRegistryTest.java new file mode 100644 index 0000000..559858e --- /dev/null +++ b/API/api/src/test/java/tc/oc/message/MessageRegistryTest.java @@ -0,0 +1,34 @@ +package tc.oc.message; + +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.Optional; +import javax.inject.Inject; + +import com.google.common.reflect.TypeToken; +import org.junit.Test; +import tc.oc.ApiTest; +import tc.oc.api.docs.Server; +import tc.oc.api.message.Message; +import tc.oc.api.message.MessageRegistry; +import tc.oc.api.message.types.ModelUpdate; + +import static org.junit.Assert.*; +import static tc.oc.test.Assert.*; + +public class MessageRegistryTest extends ApiTest { + + @Inject MessageRegistry registry; + + @Test + public void testResolveGenericMessage() throws Exception { + final TypeToken token = registry.resolve("ModelUpdate", Optional.of("Server")); + assertAssignableTo(new TypeToken>(){}, token); + + final Type type = token.getType(); + assertInstanceOf(ParameterizedType.class, type); + final ParameterizedType pType = (ParameterizedType) type; + assertEquals(ModelUpdate.class, pType.getRawType()); + assertEquals(Server.class, pType.getActualTypeArguments()[0]); + } +} diff --git a/API/bukkit/pom.xml b/API/bukkit/pom.xml new file mode 100644 index 0000000..acfe812 --- /dev/null +++ b/API/bukkit/pom.xml @@ -0,0 +1,82 @@ + + 4.0.0 + + + tc.oc + api-parent + ../pom.xml + 1.11-SNAPSHOT + + + api-bukkit + jar + API-Bukkit + ProjectAres API Bukkit plugin + + + + + com.google.guava + guava + + + + tc.oc + util-bukkit + ${project.version} + compile + + + + tc.oc + api-minecraft + ${project.version} + + + + + + + . + true + ${basedir}/src/main/resources/ + + + + + + pl.project13.maven + git-commit-id-plugin + 2.1.0 + + + + revision + + + + + + org.apache.maven.plugins + maven-shade-plugin + 2.1 + + + + tc.oc:api-minecraft + tc.oc:util-bukkit + + + + + + package + + shade + + + + + + + diff --git a/API/bukkit/src/main/java/tc/oc/api/bukkit/BukkitApiManifest.java b/API/bukkit/src/main/java/tc/oc/api/bukkit/BukkitApiManifest.java new file mode 100644 index 0000000..cffea47 --- /dev/null +++ b/API/bukkit/src/main/java/tc/oc/api/bukkit/BukkitApiManifest.java @@ -0,0 +1,49 @@ +package tc.oc.api.bukkit; + +import java.util.Optional; + +import com.google.inject.Provides; +import com.google.inject.TypeLiteral; +import org.bukkit.entity.Player; +import org.bukkit.plugin.Plugin; +import tc.oc.api.bukkit.friends.OnlineFriends; +import tc.oc.api.bukkit.users.BukkitUserStore; +import tc.oc.api.bukkit.users.Users; +import tc.oc.api.ApiManifest; +import tc.oc.api.minecraft.MinecraftApiManifest; +import tc.oc.api.minecraft.users.UserStore; +import tc.oc.bukkit.logging.RavenPlugin; +import tc.oc.commons.bukkit.inject.BukkitPluginManifest; +import tc.oc.commons.core.inject.HybridManifest; +import tc.oc.commons.core.inject.Manifest; +import tc.oc.commons.core.plugin.PluginResolver; +import tc.oc.minecraft.logging.BetterRaven; + +public final class BukkitApiManifest extends HybridManifest { + + private static class Public extends Manifest { + @Provides + Optional betterRaven(PluginResolver resolver) { + return Optional.ofNullable(resolver.getPlugin(RavenPlugin.class)).map(RavenPlugin::getRaven); + } + } + + @Override + protected void configure() { + install(new ApiManifest()); + install(new MinecraftApiManifest()); + install(new BukkitPluginManifest()); + + publicBinder().install(new Public()); + + bindAndExpose(UserStore.class).to(BukkitUserStore.class); + bindAndExpose(BukkitUserStore.class); + + bindAndExpose(tc.oc.api.minecraft.users.OnlinePlayers.class).to(tc.oc.api.bukkit.users.OnlinePlayers.class); + bindAndExpose(new TypeLiteral>(){}).to(tc.oc.api.bukkit.users.OnlinePlayers.class); + bindAndExpose(tc.oc.api.bukkit.users.OnlinePlayers.class).to(BukkitUserStore.class); + bindAndExpose(OnlineFriends.class).to(BukkitUserStore.class); + + requestStaticInjection(Users.class); + } +} diff --git a/API/bukkit/src/main/java/tc/oc/api/bukkit/event/UserUpdateEvent.java b/API/bukkit/src/main/java/tc/oc/api/bukkit/event/UserUpdateEvent.java new file mode 100644 index 0000000..4f3ba1d --- /dev/null +++ b/API/bukkit/src/main/java/tc/oc/api/bukkit/event/UserUpdateEvent.java @@ -0,0 +1,32 @@ +package tc.oc.api.bukkit.event; + +import javax.annotation.Nullable; + +import org.bukkit.entity.Player; +import org.bukkit.event.HandlerList; +import org.bukkit.event.player.PlayerEvent; +import tc.oc.api.docs.User; + +public class UserUpdateEvent extends PlayerEvent { + + private final @Nullable User before; + private final @Nullable User after; + + public UserUpdateEvent(Player player, @Nullable User before, @Nullable User after) { + super(player); + this.before = before; + this.after = after; + } + + public @Nullable User before() { + return before; + } + + public @Nullable User after() { + return after; + } + + @Override public HandlerList getHandlers() { return handlers; } + public static HandlerList getHandlerList() { return handlers; } + private static final HandlerList handlers = new HandlerList(); +} diff --git a/API/bukkit/src/main/java/tc/oc/api/bukkit/friends/OnlineFriends.java b/API/bukkit/src/main/java/tc/oc/api/bukkit/friends/OnlineFriends.java new file mode 100644 index 0000000..3306312 --- /dev/null +++ b/API/bukkit/src/main/java/tc/oc/api/bukkit/friends/OnlineFriends.java @@ -0,0 +1,51 @@ +package tc.oc.api.bukkit.friends; + +import java.util.function.Predicate; +import java.util.stream.Stream; + +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import tc.oc.api.docs.UserId; +import tc.oc.commons.core.util.Predicates; + +/** + * Map of friend relationships for currently online players. + * At least one of the two given players must be online to guarantee no false negatives. + */ +public interface OnlineFriends { + + boolean areFriends(Player a, UserId b); + boolean areFriends(Player a, Player b); + + Stream onlineFriends(UserId userId); + Stream onlineFriends(Player player); + + default boolean areFriends(CommandSender a, UserId b) { + return a instanceof Player && + areFriends((Player) a, b); + } + + default boolean areFriends(CommandSender a, CommandSender b) { + return a instanceof Player && + b instanceof Player && + areFriends((Player) a, (Player) b); + } + + default Predicate areFriends(UserId userId) { + return friend -> areFriends(friend, userId); + } + + default Predicate areFriends(Player player) { + return friend -> areFriends(friend, player); + } + + default Predicate areFriends(CommandSender player) { + return player instanceof Player ? areFriends((Player) player) + : Predicates.alwaysFalse(); + } + + default Stream onlineFriends(CommandSender player) { + return player instanceof Player ? onlineFriends((Player) player) + : Stream.empty(); + } +} diff --git a/API/bukkit/src/main/java/tc/oc/api/bukkit/users/BukkitUserStore.java b/API/bukkit/src/main/java/tc/oc/api/bukkit/users/BukkitUserStore.java new file mode 100644 index 0000000..8038393 --- /dev/null +++ b/API/bukkit/src/main/java/tc/oc/api/bukkit/users/BukkitUserStore.java @@ -0,0 +1,77 @@ +package tc.oc.api.bukkit.users; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Stream; +import javax.annotation.Nullable; +import javax.inject.Inject; +import javax.inject.Singleton; + +import org.bukkit.entity.Player; +import org.bukkit.event.EventBus; +import tc.oc.api.bukkit.event.UserUpdateEvent; +import tc.oc.api.bukkit.friends.OnlineFriends; +import tc.oc.api.docs.User; +import tc.oc.api.docs.UserId; +import tc.oc.api.minecraft.users.UserStore; +import tc.oc.api.users.UserService; +import tc.oc.minecraft.scheduler.MainThreadExecutor; + +@Singleton +public class BukkitUserStore extends UserStore implements OnlinePlayers, OnlineFriends { + + @Inject private EventBus eventBus; + @Inject private MainThreadExecutor executor; + @Inject private UserService userService; + + @Override + protected void updateUser(tc.oc.minecraft.api.entity.Player player, @Nullable User before, @Nullable User after) { + eventBus.callEvent(new UserUpdateEvent((Player) player, before, after), + event -> super.updateUser(player, before, after)); + } + + @Override + public boolean areFriends(Player a, UserId b) { + final User aUser = tryUser(a); + return aUser != null && aUser.friends().contains(b); + } + + @Override + public boolean areFriends(Player a, Player b) { + final User bUser = tryUser(b); + return bUser != null && areFriends(a, bUser); + } + + @Override + public Stream onlineFriends(UserId userId) { + return all().stream().filter(player -> areFriends(player, userId)); + } + + @Override + public Stream onlineFriends(Player player) { + final User user = tryUser(player); + return user == null ? Stream.empty() : onlineFriends(user); + } + + public void refresh(final Collection players) { + final Map idToKey = new HashMap<>(); + for(Player player : players) { + User user = tryUser(player); + if(user != null) { + idToKey.put(user._id(), player); + } + } + + if(idToKey.isEmpty()) return; + + executor.callback(userService.find(idToKey.keySet()), (result) -> { + for(User user : result.documents()) { + final Player player = idToKey.get(user._id()); + if(player != null) { + replaceUser(player, user); + } + } + }); + } +} diff --git a/API/bukkit/src/main/java/tc/oc/api/bukkit/users/OnlinePlayers.java b/API/bukkit/src/main/java/tc/oc/api/bukkit/users/OnlinePlayers.java new file mode 100644 index 0000000..2d64de0 --- /dev/null +++ b/API/bukkit/src/main/java/tc/oc/api/bukkit/users/OnlinePlayers.java @@ -0,0 +1,5 @@ +package tc.oc.api.bukkit.users; + +import org.bukkit.entity.Player; + +public interface OnlinePlayers extends tc.oc.api.minecraft.users.OnlinePlayers {} diff --git a/API/bukkit/src/main/java/tc/oc/api/bukkit/users/Users.java b/API/bukkit/src/main/java/tc/oc/api/bukkit/users/Users.java new file mode 100644 index 0000000..c70fd4f --- /dev/null +++ b/API/bukkit/src/main/java/tc/oc/api/bukkit/users/Users.java @@ -0,0 +1,47 @@ +package tc.oc.api.bukkit.users; + +import javax.annotation.Nullable; +import javax.inject.Inject; + +import org.bukkit.Bukkit; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import tc.oc.api.docs.PlayerId; +import tc.oc.api.docs.UserId; + +public class Users { + + @Inject private static BukkitUserStore userStore; + + /** + * This method is for legacy support ONLY. + * + * Use {@link BukkitUserStore#getUser(UserId)} instead. + */ + @Deprecated + public static PlayerId playerId(Player player) { + return userStore.getUser(player); + } + + /** + * This method is for legacy support ONLY. + * + * Use {@link OnlinePlayers#find(UserId)} instead. + */ + @Deprecated + public static @Nullable Player player(PlayerId playerId) { + return Bukkit.getPlayerExact(playerId.username()); + } + + public static boolean equals(PlayerId playerId, Player player) { + return playerId.username().equals(player.getName()); + } + + public static boolean equals(PlayerId playerId, CommandSender sender) { + return sender instanceof Player && equals(playerId, (Player) sender); + } + + public static boolean isOnline(PlayerId playerId) { + return player(playerId) != null; + } +} diff --git a/API/bukkit/src/main/resources/plugin.yml b/API/bukkit/src/main/resources/plugin.yml new file mode 100644 index 0000000..b9a5af1 --- /dev/null +++ b/API/bukkit/src/main/resources/plugin.yml @@ -0,0 +1,6 @@ +name: API +main: tc.oc.api.bukkit.BukkitApiManifest +version: ${project.version}-${git.commit.id.abbrev} +author: Overcast Network +isolate: true +depend: [Raven] diff --git a/API/bungee/pom.xml b/API/bungee/pom.xml new file mode 100644 index 0000000..69d78c4 --- /dev/null +++ b/API/bungee/pom.xml @@ -0,0 +1,76 @@ + + 4.0.0 + + + tc.oc + api-parent + ../pom.xml + 1.11-SNAPSHOT + + + api-bungee + jar + API-Bungee + ProjectAres API Bungee plugin + + + + tc.oc + util-bungee + ${project.version} + compile + + + + tc.oc + api-minecraft + ${project.version} + + + + + + + . + true + ${basedir}/src/main/resources/ + + + + + + pl.project13.maven + git-commit-id-plugin + 2.1.0 + + + + revision + + + + + + org.apache.maven.plugins + maven-shade-plugin + 2.1 + + + + tc.oc:api-minecraft + tc.oc:util-bungee + + + + + + package + + shade + + + + + + + diff --git a/API/bungee/src/main/java/tc/oc/api/bungee/BungeeApiManifest.java b/API/bungee/src/main/java/tc/oc/api/bungee/BungeeApiManifest.java new file mode 100644 index 0000000..adfdb26 --- /dev/null +++ b/API/bungee/src/main/java/tc/oc/api/bungee/BungeeApiManifest.java @@ -0,0 +1,44 @@ +package tc.oc.api.bungee; + +import java.util.Optional; + +import com.google.inject.Provides; +import com.google.inject.TypeLiteral; +import net.md_5.bungee.api.connection.ProxiedPlayer; +import net.md_5.bungee.api.plugin.Plugin; +import tc.oc.api.ApiManifest; +import tc.oc.api.bungee.users.BungeeUserStore; +import tc.oc.api.minecraft.MinecraftApiManifest; +import tc.oc.api.minecraft.users.UserStore; +import tc.oc.bungee.logging.RavenPlugin; +import tc.oc.commons.bungee.inject.BungeePluginManifest; +import tc.oc.commons.core.inject.HybridManifest; +import tc.oc.commons.core.inject.Manifest; +import tc.oc.commons.core.plugin.PluginResolver; +import tc.oc.minecraft.logging.BetterRaven; + +public final class BungeeApiManifest extends HybridManifest { + + public static class Public extends Manifest { + @Provides + Optional betterRaven(PluginResolver resolver) { + return Optional.ofNullable(resolver.getPlugin(RavenPlugin.class)).map(RavenPlugin::getRaven); + } + } + + @Override + protected void configure() { + install(new ApiManifest()); + install(new MinecraftApiManifest()); + install(new BungeePluginManifest()); + + publicBinder().install(new Public()); + + bindAndExpose(UserStore.class).to(BungeeUserStore.class); + bindAndExpose(BungeeUserStore.class); + + bindAndExpose(tc.oc.api.minecraft.users.OnlinePlayers.class).to(tc.oc.api.bungee.users.OnlinePlayers.class); + bindAndExpose(new TypeLiteral>(){}).to(tc.oc.api.bungee.users.OnlinePlayers.class); + bindAndExpose(tc.oc.api.bungee.users.OnlinePlayers.class).to(BungeeUserStore.class); + } +} diff --git a/API/bungee/src/main/java/tc/oc/api/bungee/users/BungeeUserStore.java b/API/bungee/src/main/java/tc/oc/api/bungee/users/BungeeUserStore.java new file mode 100644 index 0000000..9f4df16 --- /dev/null +++ b/API/bungee/src/main/java/tc/oc/api/bungee/users/BungeeUserStore.java @@ -0,0 +1,19 @@ +package tc.oc.api.bungee.users; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import net.md_5.bungee.api.ProxyServer; +import net.md_5.bungee.api.connection.ProxiedPlayer; +import tc.oc.api.minecraft.users.UserStore; + +@Singleton +public class BungeeUserStore extends UserStore implements OnlinePlayers { + + @Inject private ProxyServer proxy; + + @Override + public int count() { + return proxy.getOnlineCount(); + } +} diff --git a/API/bungee/src/main/java/tc/oc/api/bungee/users/OnlinePlayers.java b/API/bungee/src/main/java/tc/oc/api/bungee/users/OnlinePlayers.java new file mode 100644 index 0000000..f070670 --- /dev/null +++ b/API/bungee/src/main/java/tc/oc/api/bungee/users/OnlinePlayers.java @@ -0,0 +1,5 @@ +package tc.oc.api.bungee.users; + +import net.md_5.bungee.api.connection.ProxiedPlayer; + +public interface OnlinePlayers extends tc.oc.api.minecraft.users.OnlinePlayers {} diff --git a/API/bungee/src/main/resources/plugin.yml b/API/bungee/src/main/resources/plugin.yml new file mode 100644 index 0000000..2df4b59 --- /dev/null +++ b/API/bungee/src/main/resources/plugin.yml @@ -0,0 +1,5 @@ +name: API +main: tc.oc.api.bungee.BungeeApiManifest +version: ${project.version}-${git.commit.id.abbrev} +author: Overcast Network +depends: [Raven] diff --git a/API/minecraft/pom.xml b/API/minecraft/pom.xml new file mode 100644 index 0000000..cdb239e --- /dev/null +++ b/API/minecraft/pom.xml @@ -0,0 +1,48 @@ + + 4.0.0 + + + tc.oc + api-parent + ../pom.xml + 1.11-SNAPSHOT + + + api-minecraft + jar + API-Minecraft + Common API utilities for Minecraft-based servers (bungee and bukkit) + + + + tc.oc + api + ${project.version} + + + + + + + org.apache.maven.plugins + maven-shade-plugin + 2.1 + + + + tc.oc:api + + + + + + package + + shade + + + + + + + diff --git a/API/minecraft/src/main/java/tc/oc/api/minecraft/MinecraftApiManifest.java b/API/minecraft/src/main/java/tc/oc/api/minecraft/MinecraftApiManifest.java new file mode 100644 index 0000000..e1af169 --- /dev/null +++ b/API/minecraft/src/main/java/tc/oc/api/minecraft/MinecraftApiManifest.java @@ -0,0 +1,55 @@ +package tc.oc.api.minecraft; + +import com.google.inject.Provides; +import tc.oc.api.config.ApiConfiguration; +import tc.oc.api.connectable.ConnectableBinder; +import tc.oc.api.docs.Server; +import tc.oc.api.docs.virtual.ServerDoc; +import tc.oc.api.minecraft.config.MinecraftApiConfiguration; +import tc.oc.api.minecraft.config.MinecraftApiConfigurationImpl; +import tc.oc.api.minecraft.logging.MinecraftLoggingManifest; +import tc.oc.api.minecraft.maps.MinecraftMapsManifest; +import tc.oc.api.minecraft.model.MinecraftModelsManifest; +import tc.oc.api.minecraft.servers.MinecraftServersManifest; +import tc.oc.api.minecraft.sessions.MinecraftSessionsManifest; +import tc.oc.api.minecraft.users.MinecraftUsersManifest; +import tc.oc.commons.core.inject.HybridManifest; +import tc.oc.commons.core.inject.Manifest; +import tc.oc.debug.LeakDetectorManifest; + +public final class MinecraftApiManifest extends HybridManifest { + + private static class Public extends Manifest { + @Override + protected void configure() { + bind(ServerDoc.Identity.class).to(Server.class); + + new ConnectableBinder(binder()) + .addBinding().to(MinecraftServiceImpl.class); + } + + @Provides Server localServer(MinecraftService minecraftService) { + return minecraftService.everfreshLocalServer(); + } + } + + @Override + protected void configure() { + publicBinder().install(new Public()); + + install(new LeakDetectorManifest()); + install(new MinecraftLoggingManifest()); + install(new MinecraftModelsManifest()); + + install(new MinecraftServersManifest()); + install(new MinecraftUsersManifest()); + install(new MinecraftSessionsManifest()); + install(new MinecraftMapsManifest()); + + bindAndExpose(ApiConfiguration.class).to(MinecraftApiConfiguration.class); + bindAndExpose(MinecraftApiConfiguration.class).to(MinecraftApiConfigurationImpl.class); + + bindAndExpose(MinecraftService.class).to(MinecraftServiceImpl.class); + bindAndExpose(MinecraftServiceImpl.class); // Needs to be exposed so it can be registered as a connectable service + } +} diff --git a/API/minecraft/src/main/java/tc/oc/api/minecraft/MinecraftService.java b/API/minecraft/src/main/java/tc/oc/api/minecraft/MinecraftService.java new file mode 100644 index 0000000..0bbefae --- /dev/null +++ b/API/minecraft/src/main/java/tc/oc/api/minecraft/MinecraftService.java @@ -0,0 +1,26 @@ +package tc.oc.api.minecraft; + +import com.google.common.util.concurrent.ListenableFuture; +import tc.oc.api.docs.Server; +import tc.oc.api.docs.virtual.ServerDoc; + +/** + * Service provided by Minecraft and Bungee servers acting as API clients. + */ +public interface MinecraftService { + + /** + * Gets the full, perhaps stale, local server document that represents the + * server this process is running as. Return value will be null if the + * service is not connected. + * + * @return local server document + */ + Server getLocalServer(); + + Server everfreshLocalServer(); + + boolean isLocalServer(ServerDoc.Identity server); + + ListenableFuture updateLocalServer(ServerDoc.Partial update); +} diff --git a/API/minecraft/src/main/java/tc/oc/api/minecraft/MinecraftServiceImpl.java b/API/minecraft/src/main/java/tc/oc/api/minecraft/MinecraftServiceImpl.java new file mode 100644 index 0000000..b1a78b9 --- /dev/null +++ b/API/minecraft/src/main/java/tc/oc/api/minecraft/MinecraftServiceImpl.java @@ -0,0 +1,178 @@ +package tc.oc.api.minecraft; + +import java.io.IOException; +import java.lang.reflect.Method; +import java.util.concurrent.ExecutionException; +import java.util.logging.Level; +import java.util.logging.Logger; +import javax.annotation.Nullable; +import javax.inject.Inject; +import javax.inject.Singleton; + +import com.google.common.base.Function; +import com.google.common.eventbus.EventBus; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import tc.oc.api.connectable.Connectable; +import tc.oc.api.docs.Server; +import tc.oc.api.docs.virtual.ServerDoc; +import tc.oc.api.exceptions.ApiNotConnected; +import tc.oc.api.message.MessageListener; +import tc.oc.api.message.MessageQueue; +import tc.oc.api.message.types.ModelUpdate; +import tc.oc.api.minecraft.config.MinecraftApiConfiguration; +import tc.oc.api.minecraft.servers.LocalServerDocument; +import tc.oc.api.minecraft.servers.LocalServerReconfigureEvent; +import tc.oc.api.minecraft.servers.StartupServerDocument; +import tc.oc.api.servers.ServerService; +import tc.oc.commons.core.logging.Loggers; +import tc.oc.commons.core.reflect.Methods; +import tc.oc.commons.core.util.MethodHandleInvoker; +import tc.oc.commons.core.util.ProxyUtils; +import tc.oc.minecraft.scheduler.SyncExecutor; + +@Singleton +public class MinecraftServiceImpl implements MinecraftService, MessageListener, Connectable { + + private final Logger logger; + private final EventBus eventBus; + private final SyncExecutor syncExecutor; + private final ServerService serverService; + private final MinecraftApiConfiguration apiConfiguration; + private final StartupServerDocument startupDocument; + private final MessageQueue serverQueue; + private final Server everfreshLocalServer; + + private @Nullable Server server; + + @Inject MinecraftServiceImpl(Loggers loggers, + EventBus eventBus, + SyncExecutor syncExecutor, + ServerService serverService, + MinecraftApiConfiguration apiConfiguration, + MessageQueue serverQueue, + LocalServerDocument localServerDocument, + StartupServerDocument startupDocument) { + + this.logger = loggers.get(getClass()); + this.eventBus = eventBus; + this.syncExecutor = syncExecutor; + this.serverService = serverService; + this.apiConfiguration = apiConfiguration; + this.serverQueue = serverQueue; + + this.everfreshLocalServer = ProxyUtils.newProxy(Server.class, new MethodHandleInvoker() { + @Override + protected Object targetFor(Method method) { + if(server != null) return server; + if(Methods.respondsTo(localServerDocument, method)) return localServerDocument; + throw new ApiNotConnected(); + } + }); + this.startupDocument = startupDocument; + } + + @Override + public boolean listenWhileSuspended() { + return true; + } + + private void assertConnected() throws ApiNotConnected { + if(server == null) { + throw new ApiNotConnected(); + } + } + + @Override + public Server getLocalServer() { + assertConnected(); + return server; + } + + @Override + public boolean isLocalServer(ServerDoc.Identity server) { + return getLocalServer()._id().equals(server._id()); + } + + /** + * Return a magic {@link Server} document for the local server that + * always has the most recent data. If the API is not connected, + * the fields provided by {@link LocalServerDocument} will work, + * but trying to read any other field will throw {@link ApiNotConnected}. + */ + @Override + public Server everfreshLocalServer() { + return everfreshLocalServer; + } + + /** + * Send a server configuration change to the remote API. The API will respond with + * the latest version of the server document, and only at that point will the local + * document be modified. This is done by calling {@link #handleReconfigure}, + * and subclasses can override that method if they want to fire a reconfigure event. + * + * This method returns a future that completes after the API responds to the update + * AND the local server document has been replaced with the result. + */ + @Override + public ListenableFuture updateLocalServer(ServerDoc.Partial update) { + return Futures.transform( + serverService.update(apiConfiguration.serverId(), update), + (Function) result -> { + handleLocalReconfigure(result); + return result; + }, + syncExecutor + ); + } + + @HandleMessage + public void handleReconfigure(ModelUpdate message) { + if(server != null && server._id().equals(message.document()._id())) { + handleLocalReconfigure(message.document()); + } + } + + protected void handleLocalReconfigure(Server newConfig) { + Server oldConfig = this.server; + this.server = newConfig; + if(logger.isLoggable(Level.FINE)) { + logger.fine("Local server reconfigured: " + newConfig); + } + eventBus.post(new LocalServerReconfigureEvent(oldConfig, newConfig)); + } + + @Override + public void connect() throws IOException { + try { + serverQueue.subscribe(this, syncExecutor); + + handleLocalReconfigure(serverService.update(apiConfiguration.serverId(), startupDocument).get()); + + logger.info("Connected to API as server." + getLocalServer()._id()); + } catch (Exception e) { + this.processIntoIOException(e); + } + } + + @Override + public void disconnect() throws IOException { + try { + serverService.update( + apiConfiguration.serverId(), + (ServerDoc.Online) () -> false + ).get(); + + serverQueue.unsubscribe(this); + } catch (Exception e) { + processIntoIOException(e); + } + this.server = null; + } + + private void processIntoIOException(Throwable t) throws IOException { + if (t instanceof IOException) throw (IOException)t; + if (t instanceof ExecutionException) this.processIntoIOException(t.getCause()); + throw new IOException(t); + } +} diff --git a/API/minecraft/src/main/java/tc/oc/api/minecraft/config/MinecraftApiConfiguration.java b/API/minecraft/src/main/java/tc/oc/api/minecraft/config/MinecraftApiConfiguration.java new file mode 100644 index 0000000..0015605 --- /dev/null +++ b/API/minecraft/src/main/java/tc/oc/api/minecraft/config/MinecraftApiConfiguration.java @@ -0,0 +1,15 @@ +package tc.oc.api.minecraft.config; + +import tc.oc.api.config.ApiConfiguration; +import tc.oc.api.docs.virtual.ServerDoc; + +public interface MinecraftApiConfiguration extends ApiConfiguration { + + String serverId(); + + String datacenter(); + + String box(); + + ServerDoc.Role role(); +} diff --git a/API/minecraft/src/main/java/tc/oc/api/minecraft/config/MinecraftApiConfigurationImpl.java b/API/minecraft/src/main/java/tc/oc/api/minecraft/config/MinecraftApiConfigurationImpl.java new file mode 100644 index 0000000..aab67a3 --- /dev/null +++ b/API/minecraft/src/main/java/tc/oc/api/minecraft/config/MinecraftApiConfigurationImpl.java @@ -0,0 +1,44 @@ +package tc.oc.api.minecraft.config; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import tc.oc.api.docs.virtual.ServerDoc; +import tc.oc.minecraft.api.configuration.Configuration; + +import static com.google.common.base.Preconditions.checkNotNull; + +@Singleton +public class MinecraftApiConfigurationImpl implements MinecraftApiConfiguration { + + private final Configuration config; + + @Inject MinecraftApiConfigurationImpl(Configuration config) { + this.config = config; + } + + @Override + public String serverId() { + return checkNotNull(config.getString("server.id")); + } + + @Override + public String datacenter() { + return checkNotNull(config.getString("server.datacenter")); + } + + @Override + public String box() { + return checkNotNull(config.getString("server.box")); + } + + @Override + public ServerDoc.Role role() { + return ServerDoc.Role.valueOf(config.getString("server.role").toUpperCase()); + } + + @Override + public String primaryQueueName() { + return "server." + serverId(); + } +} diff --git a/API/minecraft/src/main/java/tc/oc/api/minecraft/logging/LoggingCommands.java b/API/minecraft/src/main/java/tc/oc/api/minecraft/logging/LoggingCommands.java new file mode 100644 index 0000000..f2bf848 --- /dev/null +++ b/API/minecraft/src/main/java/tc/oc/api/minecraft/logging/LoggingCommands.java @@ -0,0 +1,231 @@ +package tc.oc.api.minecraft.logging; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.logging.Level; +import java.util.logging.LogManager; +import java.util.logging.Logger; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import javax.inject.Inject; + +import com.google.common.base.Strings; +import com.sk89q.minecraft.util.commands.Command; +import com.sk89q.minecraft.util.commands.CommandContext; +import com.sk89q.minecraft.util.commands.CommandException; +import com.sk89q.minecraft.util.commands.CommandPermissions; +import com.sk89q.minecraft.util.commands.NestedCommand; +import com.sk89q.minecraft.util.commands.SuggestException; +import net.md_5.bungee.api.ChatColor; +import org.apache.logging.log4j.spi.LoggerContext; +import tc.oc.api.util.Permissions; +import tc.oc.commons.core.commands.Commands; +import tc.oc.commons.core.commands.NestedCommands; +import tc.oc.commons.core.logging.Logging; +import tc.oc.commons.core.logging.LoggingConfig; +import tc.oc.minecraft.api.command.CommandSender; + +public class LoggingCommands implements NestedCommands { + + private final LoggingConfig loggingConfig; + + @Inject LoggingCommands(LoggingConfig loggingConfig) { + this.loggingConfig = loggingConfig; + } + + @Override + public void enable() { + // Verify this reflection magic works + for(LoggerContext context : Logging.L4J.getContexts()) { + Logging.L4J.getLoggers(context); + } + } + + private static String levelName(Level level) { + if(level == Level.WARNING) { + return "WARN"; + } else if(level == null) { + return "parent"; + } else { + return level.getName(); + } + } + + private static String colorLevelName(Level level) { + return Logging.levelColor(level) + levelName(level) + ChatColor.RESET; + } + + private static String colorLevelName(org.apache.logging.log4j.Level level) { + return Logging.levelColor(level) + level.name() + ChatColor.RESET; + } + + private static String paddedLevelName(Level level) { + return Logging.levelColor(level) + Strings.padEnd(levelName(level), 6, ' ') + ChatColor.RESET; + } + + private static String paddedLevelName(org.apache.logging.log4j.Level level) { + return Logging.levelColor(level) + Strings.padEnd(level.name(), 6, ' ') + ChatColor.RESET; + } + + private static String loggerName(String literal) { + if(literal == null || literal.length() == 0) { + return ""; + } else { + return literal; + } + } + + private static String loggerName(Logger logger) { + return loggerName(logger.getName()); + } + + private String loggerNameArg(CommandContext args, int index) throws CommandException, SuggestException { + return args.string(index, Stream.concat(Logging.loggerNames(), + Logging.L4J.loggerNames()) + .sorted() + .collect(Collectors.toList())); + } + + public static class Parent implements Commands { + @Command( + aliases = "log", + desc = "Commands related to logging", + min = 1, + max = -1 + ) + @NestedCommand(LoggingCommands.class) + @CommandPermissions(Permissions.DEVELOPER) + public void log(CommandContext args, CommandSender sender) throws CommandException {} + } + + @Command( + aliases = "list", + desc = "List all registered loggers", + usage = "[prefix]", + min = 0, + max = 1 + ) + public void list(CommandContext args, CommandSender sender) throws CommandException { + String prefix = args.getString(0, ""); + + for(LoggerContext context : Logging.L4J.getContexts()) { + Map loggers = Logging.L4J.getLoggers(context); + if(!loggers.isEmpty()) { + List names = new ArrayList<>(loggers.keySet()); + Collections.sort(names); + boolean first = true; + + for(String name : names) { + if(name.startsWith(prefix)) { + if(first) { + first = false; + sender.sendMessage(ChatColor.YELLOW + "log4j Loggers (" + Logging.L4J.getContextName(context) + "):"); + } + org.apache.logging.log4j.Logger logger = loggers.get(name); + sender.sendMessage("[" + paddedLevelName(Logging.L4J.getLevel(logger)) + + "] [" + paddedLevelName(Logging.L4J.getEffectiveLevel(logger)) + + "] " + loggerName(logger.getName())); + } + } + } + } + + LogManager lm = LogManager.getLogManager(); + List names = Collections.list(lm.getLoggerNames()); + if(!names.isEmpty()) { + sender.sendMessage(ChatColor.YELLOW + "java.util.logging Loggers:"); + Collections.sort(names); + for(String name : names) { + if(name.startsWith(prefix)) { + Logger logger = lm.getLogger(name); + if(logger != null) { + sender.sendMessage("[" + paddedLevelName(logger.getLevel()) + + "] [" + paddedLevelName(Logging.getEffectiveLevel(logger)) + + "] " + loggerName(name)); + } + } + } + } + } + + @Command( + aliases = "level", + desc = "Set or reset the level of a logger, or all loggers", + usage = " [jul|l4j] [root | ]", + min = 1, + max = 3 + ) + public void level(CommandContext args, CommandSender sender) throws CommandException, SuggestException { + String levelName = args.getString(0).toUpperCase(); + + boolean jul = true, l4j = true; + if(args.argsLength() >= 2) { + String subsystem = args.getString(1).toLowerCase(); + if("jul".equals(subsystem)) { + l4j = false; + } else if("l4j".equals(subsystem)) { + jul = false; + } + } + + String loggerName = loggerNameArg(args, jul && l4j ? 1 : 2); + + if(jul) { + Logger julLogger = Logging.findLogger(loggerName); + if(julLogger != null) { + Level level; + if("RESET".equals(levelName)) { + level = null; + } else { + level = Level.parse(levelName); + } + julLogger.setLevel(level); + sender.sendMessage(ChatColor.WHITE + "Logger " + loggerName(julLogger) + + " level " + (level == null ? "reset" : "set to " + colorLevelName(level))); + return; + } + } + + if(l4j) { + org.apache.logging.log4j.Logger l4jLogger = Logging.L4J.findLogger(loggerName); + if(l4jLogger != null) { + org.apache.logging.log4j.Level level = org.apache.logging.log4j.Level.valueOf(levelName); + Logging.L4J.setLevel(l4jLogger, level); + sender.sendMessage(ChatColor.WHITE + "Logger " + l4jLogger.getName() + + " level " + (level == null ? "reset" : "set to " + colorLevelName(level))); + return; + } + } + + throw new CommandException("No logger named '" + loggerName + "'"); + } + + @Command( + aliases = "props", + desc = "Dump the current JUL logging properties", + min = 0, + max = 0 + ) + public void props(CommandContext args, CommandSender sender) throws CommandException { + try { + for(Map.Entry entry : Logging.getLoggingProperties().entrySet()) { + sender.sendMessage(entry.getKey() + "=" + entry.getValue()); + } + } catch(IllegalAccessException | NoSuchFieldException e) { + throw new CommandException("Failed to get JUL logging properties", e); + } + } + + @Command( + aliases = "load", + desc = "Reload the logging configuration", + min = 0, + max = 0 + ) + public void load(CommandContext args, CommandSender sender) throws CommandException { + loggingConfig.load(); + sender.sendMessage("Logging configuration reloaded"); + } +} diff --git a/API/minecraft/src/main/java/tc/oc/api/minecraft/logging/MinecraftLoggingManifest.java b/API/minecraft/src/main/java/tc/oc/api/minecraft/logging/MinecraftLoggingManifest.java new file mode 100644 index 0000000..39e5c18 --- /dev/null +++ b/API/minecraft/src/main/java/tc/oc/api/minecraft/logging/MinecraftLoggingManifest.java @@ -0,0 +1,16 @@ +package tc.oc.api.minecraft.logging; + +import tc.oc.commons.core.inject.HybridManifest; +import tc.oc.commons.core.plugin.PluginFacetBinder; + +public class MinecraftLoggingManifest extends HybridManifest { + + @Override + protected void configure() { + final PluginFacetBinder facets = new PluginFacetBinder(binder()); + facets.register(LoggingCommands.class); + facets.register(LoggingCommands.Parent.class); + facets.register(NotOurProblemRavenFilter.class); + facets.register(RavenServerTagger.class); + } +} diff --git a/API/minecraft/src/main/java/tc/oc/api/minecraft/logging/NotOurProblemRavenFilter.java b/API/minecraft/src/main/java/tc/oc/api/minecraft/logging/NotOurProblemRavenFilter.java new file mode 100644 index 0000000..d3c98d3 --- /dev/null +++ b/API/minecraft/src/main/java/tc/oc/api/minecraft/logging/NotOurProblemRavenFilter.java @@ -0,0 +1,36 @@ +package tc.oc.api.minecraft.logging; + +import java.util.Optional; +import java.util.Set; +import javax.inject.Inject; + +import com.google.common.collect.ImmutableSet; +import tc.oc.commons.core.plugin.PluginFacet; +import tc.oc.minecraft.logging.BetterRaven; + +/** + * Don't report other people's errors to us + */ +public class NotOurProblemRavenFilter implements PluginFacet { + + private static final Set BLACKLIST = ImmutableSet.of( + "com.sk89q.worldedit" + ); + + private final Optional raven; + + @Inject NotOurProblemRavenFilter(Optional raven) { + this.raven = raven; + } + + @Override + public void enable() { + raven.ifPresent( + raven -> raven.addFilter( + record -> BLACKLIST.stream().noneMatch( + prefix -> record.getLoggerName().startsWith(prefix) + ) + ) + ); + } +} diff --git a/API/minecraft/src/main/java/tc/oc/api/minecraft/logging/RavenServerTagger.java b/API/minecraft/src/main/java/tc/oc/api/minecraft/logging/RavenServerTagger.java new file mode 100644 index 0000000..7947469 --- /dev/null +++ b/API/minecraft/src/main/java/tc/oc/api/minecraft/logging/RavenServerTagger.java @@ -0,0 +1,51 @@ +package tc.oc.api.minecraft.logging; + +import java.util.Map; +import java.util.Optional; +import javax.inject.Inject; + +import net.kencochrane.raven.event.EventBuilder; +import net.kencochrane.raven.event.helper.EventBuilderHelper; +import tc.oc.api.docs.Server; +import tc.oc.api.docs.virtual.DeployInfo; +import tc.oc.api.exceptions.ApiNotConnected; +import tc.oc.api.minecraft.config.MinecraftApiConfiguration; +import tc.oc.commons.core.plugin.PluginFacet; +import tc.oc.minecraft.logging.BetterRaven; + +/** + * Tags Sentry events with the identity of the local server + */ +public class RavenServerTagger implements EventBuilderHelper, PluginFacet { + + private final MinecraftApiConfiguration config; + private final Server server; + + @Inject RavenServerTagger(Optional raven, MinecraftApiConfiguration config, Server server) { + this.config = config; + this.server = server; + raven.ifPresent(r -> r.addBuilderHelper(this)); + } + + @Override + public void helpBuildingEvent(EventBuilder eventBuilder) { + try { + eventBuilder.addTag("datacenter", config.datacenter()); + eventBuilder.addTag("server", server.name()); + eventBuilder.addTag("server_slug", server.bungee_name()); + eventBuilder.addTag("family", server.family()); + eventBuilder.addTag("box", config.box()); + + if(server.deploy_info() != null) { + eventBuilder.addTag("nextgen_branch", server.deploy_info().nextgen().version().branch()); + eventBuilder.addTag("nextgen_commit", server.deploy_info().nextgen().version().commit()); + + for(Map.Entry pack : server.deploy_info().packages().entrySet()) { + eventBuilder.addTag(pack.getKey() + "_commit", pack.getValue().commit()); + } + } + } catch(ApiNotConnected e) { + eventBuilder.addTag("server_id", config.serverId()); + } + } +} diff --git a/API/minecraft/src/main/java/tc/oc/api/minecraft/maps/LocalMapService.java b/API/minecraft/src/main/java/tc/oc/api/minecraft/maps/LocalMapService.java new file mode 100644 index 0000000..fc3d032 --- /dev/null +++ b/API/minecraft/src/main/java/tc/oc/api/minecraft/maps/LocalMapService.java @@ -0,0 +1,48 @@ +package tc.oc.api.minecraft.maps; + +import java.util.Collection; +import java.util.Collections; +import java.util.stream.Stream; +import javax.inject.Inject; +import javax.inject.Singleton; + +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import tc.oc.api.docs.MapRating; +import tc.oc.api.docs.virtual.MapDoc; +import tc.oc.api.maps.MapRatingsRequest; +import tc.oc.api.maps.MapRatingsResponse; +import tc.oc.api.maps.MapService; +import tc.oc.api.maps.MapUpdateMultiResponse; +import tc.oc.api.minecraft.users.UserStore; +import tc.oc.api.model.NullModelService; +import tc.oc.commons.core.stream.Collectors; +import tc.oc.minecraft.api.entity.Player; + +@Singleton +public class LocalMapService extends NullModelService implements MapService { + + @Inject private UserStore userStore; + + @Override + public ListenableFuture rate(MapRating rating) { + return Futures.immediateFuture(null); + } + + @Override + public ListenableFuture getRatings(MapRatingsRequest request) { + return Futures.immediateFuture(Collections::emptyMap); + } + + @Override + public ListenableFuture updateMapsAndLookupAuthors(Collection maps) { + return Futures.immediateFuture(new MapUpdateMultiResponse( + maps.stream() + .flatMap(map -> Stream.concat(map.author_uuids().stream(), + map.contributor_uuids().stream())) + .collect(Collectors.mappingTo(uuid -> userStore.byUuid(uuid) + .flatMap(userStore::user) + .orElse(null))) + )); + } +} diff --git a/API/minecraft/src/main/java/tc/oc/api/minecraft/maps/MinecraftMapsManifest.java b/API/minecraft/src/main/java/tc/oc/api/minecraft/maps/MinecraftMapsManifest.java new file mode 100644 index 0000000..1f5b8bf --- /dev/null +++ b/API/minecraft/src/main/java/tc/oc/api/minecraft/maps/MinecraftMapsManifest.java @@ -0,0 +1,13 @@ +package tc.oc.api.minecraft.maps; + +import tc.oc.api.maps.MapService; +import tc.oc.commons.core.inject.HybridManifest; + +public class MinecraftMapsManifest extends HybridManifest { + + @Override + protected void configure() { + publicBinder().forOptional(MapService.class) + .setDefault().to(LocalMapService.class); + } +} diff --git a/API/minecraft/src/main/java/tc/oc/api/minecraft/model/MinecraftModelsManifest.java b/API/minecraft/src/main/java/tc/oc/api/minecraft/model/MinecraftModelsManifest.java new file mode 100644 index 0000000..762e3ee --- /dev/null +++ b/API/minecraft/src/main/java/tc/oc/api/minecraft/model/MinecraftModelsManifest.java @@ -0,0 +1,24 @@ +package tc.oc.api.minecraft.model; + +import java.util.concurrent.ExecutorService; + +import com.google.inject.Key; +import tc.oc.api.model.ModelSync; +import tc.oc.commons.core.inject.HybridManifest; +import tc.oc.commons.core.plugin.PluginFacetBinder; +import tc.oc.minecraft.scheduler.Sync; + +public class MinecraftModelsManifest extends HybridManifest { + + @Override + protected void configure() { + // We want a global binding for @ModelSync ExecutorService, but each plugin has + // its own executors, so just use the API plugin's executor globally. + bind(Key.get(ExecutorService.class, ModelSync.class)) + .to(Key.get(ExecutorService.class, Sync.immediate)); + + final PluginFacetBinder facets = new PluginFacetBinder(binder()); + facets.register(ModelCommands.class); + facets.register(ModelCommands.Parent.class); + } +} diff --git a/API/minecraft/src/main/java/tc/oc/api/minecraft/model/ModelCommands.java b/API/minecraft/src/main/java/tc/oc/api/minecraft/model/ModelCommands.java new file mode 100644 index 0000000..51c24df --- /dev/null +++ b/API/minecraft/src/main/java/tc/oc/api/minecraft/model/ModelCommands.java @@ -0,0 +1,156 @@ +package tc.oc.api.minecraft.model; + +import java.util.List; +import java.util.Optional; +import javax.annotation.Nullable; +import javax.inject.Inject; + +import com.google.gson.Gson; +import com.sk89q.minecraft.util.commands.Command; +import com.sk89q.minecraft.util.commands.CommandContext; +import com.sk89q.minecraft.util.commands.CommandException; +import com.sk89q.minecraft.util.commands.CommandPermissions; +import com.sk89q.minecraft.util.commands.NestedCommand; +import tc.oc.api.docs.virtual.Model; +import tc.oc.minecraft.scheduler.SyncExecutor; +import tc.oc.api.model.ModelMeta; +import tc.oc.api.model.ModelRegistry; +import tc.oc.api.serialization.Pretty; +import tc.oc.api.util.Permissions; +import tc.oc.commons.core.chat.Component; +import tc.oc.commons.core.commands.Commands; +import tc.oc.commons.core.commands.NestedCommands; +import tc.oc.commons.core.formatting.StringUtils; +import tc.oc.minecraft.api.command.CommandSender; + +class ModelCommands implements NestedCommands { + + public static class Parent implements Commands { + @Command( + aliases = "model", + desc = "Commands related to API models", + min = 1, + max = -1 + ) + @NestedCommand(ModelCommands.class) + @CommandPermissions(Permissions.DEVELOPER) + public void model(CommandContext args, CommandSender sender) throws CommandException {} + } + + private final ModelRegistry registry; + private final Gson prettyGson; + private final SyncExecutor syncExecutor; + + @Inject ModelCommands(ModelRegistry registry, @Pretty Gson prettyGson, SyncExecutor syncExecutor) { + this.registry = registry; + this.prettyGson = prettyGson; + this.syncExecutor = syncExecutor; + } + + private List completeModel(String name) { + return StringUtils.complete(name, registry.all() + .stream() + .filter(meta -> meta.store().isPresent()) + .map(ModelMeta::name)); + } + + private ModelMeta parseModel(@Nullable String name) throws CommandException { + if(name == null) return null; + + for(ModelMeta meta : registry.all()) { + if(meta.store().isPresent() && name.equalsIgnoreCase(meta.name())) { + return meta; + } + } + throw new CommandException("Unknown model '" + name + "'"); + } + + @Command( + aliases = "list", + desc = "List all stored models", + max = 0 + ) + public void list(CommandContext args, CommandSender sender) throws CommandException { + for(ModelMeta meta : registry.all()) { + if(meta.store().isPresent()) { + sender.sendMessage(new Component(meta.name() + " (" + meta.store().get().count() + ")")); + } + } + } + + @Command( + aliases = "all", + usage = "", + desc = "List all stored instances of a model", + min = 1, + max = 1 + ) + public List all(CommandContext args, CommandSender sender) throws CommandException { + final String modelName = args.getString(0, ""); + + if(args.getSuggestionContext() != null) { + return completeModel(modelName); + } + for(Model doc : parseModel(modelName).store().get().set()) { + sender.sendMessage(new Component(doc._id() + " " + doc.toShortString())); + } + return null; + } + + @Command( + aliases = "show", + usage = " ", + desc = "Show the contents of a single document", + min = 2, + max = 2 + ) + public List show(CommandContext args, CommandSender sender) throws CommandException { + final String modelName = args.getString(0, ""); + final String id = args.getString(1, ""); + + if(args.getSuggestionContext() != null) { + switch(args.getSuggestionContext().getIndex()) { + case 0: + return completeModel(modelName); + + case 1: + final ModelMeta meta = parseModel(modelName); + return StringUtils.complete(id, meta.store().get().set().stream().map(Model::_id)); + + default: + return null; + } + } + + final ModelMeta meta = parseModel(modelName); + final Optional doc = meta.store().get().tryId(id); + if(!doc.isPresent()) { + throw new CommandException("No " + meta.name() + " with _id " + id); + } + sender.sendMessage(new Component(prettyGson.toJson(doc.get()))); + return null; + } + + @Command( + aliases = "refresh", + usage = "", + desc = "Refresh a model store from the API", + min = 1, + max = 1 + ) + public List refresh(CommandContext args, CommandSender sender) throws CommandException { + final String modelName = args.getString(0, ""); + + if(args.getSuggestionContext() != null) { + return completeModel(modelName); + } + + final ModelMeta model = parseModel(modelName); + sender.sendMessage("Refreshing model " + model.name() + "..."); + syncExecutor.callback( + model.store().get().refreshAll(), + response -> sender.sendMessage("Refreshed " + response.documents().size() + " " + model.name() + " document(s)") + ); + return null; + } +} diff --git a/API/minecraft/src/main/java/tc/oc/api/minecraft/queue/MinecraftQueueManifest.java b/API/minecraft/src/main/java/tc/oc/api/minecraft/queue/MinecraftQueueManifest.java new file mode 100644 index 0000000..86b6c89 --- /dev/null +++ b/API/minecraft/src/main/java/tc/oc/api/minecraft/queue/MinecraftQueueManifest.java @@ -0,0 +1,16 @@ +package tc.oc.api.minecraft.queue; + +import tc.oc.api.queue.QueueManifest; +import tc.oc.commons.core.inject.HybridManifest; +import tc.oc.commons.core.plugin.PluginFacetBinder; + +public class MinecraftQueueManifest extends HybridManifest { + + @Override + protected void configure() { + install(new QueueManifest()); + + new PluginFacetBinder(binder()) + .register(QueueCommands.Parent.class); + } +} diff --git a/API/minecraft/src/main/java/tc/oc/api/minecraft/queue/QueueCommands.java b/API/minecraft/src/main/java/tc/oc/api/minecraft/queue/QueueCommands.java new file mode 100644 index 0000000..efe2f0b --- /dev/null +++ b/API/minecraft/src/main/java/tc/oc/api/minecraft/queue/QueueCommands.java @@ -0,0 +1,88 @@ +package tc.oc.api.minecraft.queue; + +import javax.inject.Inject; + +import com.sk89q.minecraft.util.commands.Command; +import com.sk89q.minecraft.util.commands.CommandContext; +import com.sk89q.minecraft.util.commands.CommandException; +import com.sk89q.minecraft.util.commands.CommandPermissions; +import com.sk89q.minecraft.util.commands.NestedCommand; +import tc.oc.api.message.types.Ping; +import tc.oc.api.message.types.Reply; +import tc.oc.commons.core.commands.CommandFutureCallback; +import tc.oc.commons.core.commands.Commands; +import tc.oc.commons.core.commands.NestedCommands; +import tc.oc.minecraft.scheduler.SyncExecutor; +import tc.oc.api.queue.Exchange; +import tc.oc.api.queue.Transaction; +import tc.oc.api.util.Permissions; +import tc.oc.commons.core.chat.Audience; +import tc.oc.commons.core.chat.Audiences; +import tc.oc.minecraft.api.command.CommandSender; + +/** + * AMQP debugging commands + */ +class QueueCommands implements NestedCommands { + public static class Parent implements Commands { + @Command( + aliases = "amqp", + desc = "AMQP testing commands", + min = 1, + max = -1 + ) + @CommandPermissions(Permissions.DEVELOPER) + @NestedCommand(QueueCommands.class) + public void amqp(CommandContext args, CommandSender sender) throws CommandException {} + } + + private final Exchange.Direct exchange; + private final Transaction.Factory transactions; + private final SyncExecutor syncExecutor; + private final Audiences audiences; + + @Inject QueueCommands(Exchange.Direct exchange, Transaction.Factory transactions, SyncExecutor syncExecutor, Audiences audiences) { + this.exchange = exchange; + this.transactions = transactions; + this.syncExecutor = syncExecutor; + this.audiences = audiences; + } + + @Command( + aliases = {"ping"}, + desc = "Send a Ping message to the direct exchange, -r to wait for a reply", + usage = " [-s | -f | -e]", + flags = "sfe", + min = 1, + max = 1 + ) + public void ping(CommandContext args, CommandSender sender) throws CommandException { + final String routingKey = args.getString(0); + final Audience audience = audiences.get(sender); + + audience.sendMessage("ping " + routingKey); + + final Ping.ReplyWith replyWith; + if(args.hasFlag('s')) { + replyWith = Ping.ReplyWith.success; + } else if(args.hasFlag('f')) { + replyWith = Ping.ReplyWith.failure; + } else if(args.hasFlag('e')) { + replyWith = Ping.ReplyWith.exception; + } else { + replyWith = null; + } + + if(replyWith == null) { + exchange.publishAsync(new Ping(), routingKey); + } else { + final Transaction transaction = transactions.request(new Ping(replyWith), routingKey); + syncExecutor.callback( + transaction, + CommandFutureCallback.onSuccess(sender, args, reply -> { + audience.sendMessage(reply.getClass().getSimpleName() + String.format(" (%.3fms)", transaction.elapsedTimeMillis())); + }) + ); + } + } +} diff --git a/API/minecraft/src/main/java/tc/oc/api/minecraft/servers/LocalServerDocument.java b/API/minecraft/src/main/java/tc/oc/api/minecraft/servers/LocalServerDocument.java new file mode 100644 index 0000000..9e3d1c9 --- /dev/null +++ b/API/minecraft/src/main/java/tc/oc/api/minecraft/servers/LocalServerDocument.java @@ -0,0 +1,285 @@ +package tc.oc.api.minecraft.servers; + +import java.time.Instant; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import javax.annotation.Nullable; +import javax.inject.Inject; +import javax.inject.Singleton; + +import com.google.common.collect.ImmutableMap; +import tc.oc.api.docs.Server; +import tc.oc.api.docs.team; +import tc.oc.api.docs.virtual.MapDoc; +import tc.oc.api.docs.virtual.MatchDoc; +import tc.oc.api.docs.virtual.ServerDoc; +import tc.oc.api.minecraft.config.MinecraftApiConfiguration; +import tc.oc.minecraft.api.entity.OfflinePlayer; +import tc.oc.minecraft.api.server.LocalServer; + +@Singleton +public class LocalServerDocument extends StartupServerDocument implements Server { + + @Inject private MinecraftApiConfiguration config; + @Inject private LocalServer minecraftServer; + + private @Nullable ServerDoc.StatusUpdate status; + private @Nullable ServerDoc.MatchStatusUpdate matchStatus; + private @Nullable ServerDoc.Mutation mutations; + + void update(ServerDoc.Partial doc) { + if(doc instanceof ServerDoc.MatchStatusUpdate) { + this.status = (ServerDoc.StatusUpdate) doc; + this.matchStatus = (ServerDoc.MatchStatusUpdate) doc; + } else if(doc instanceof ServerDoc.StatusUpdate) { + this.status = (ServerDoc.StatusUpdate) doc; + this.matchStatus = null; + } + + if(doc instanceof ServerDoc.Mutation) { + this.mutations = (ServerDoc.Mutation) doc; + } + } + + @Override + public String _id() { + return config.serverId(); + } + + @Override + public String datacenter() { + return config.datacenter(); + } + + @Override + public String box() { + return config.box(); + } + + @Override + public ServerDoc.Role role() { + return config.role(); + } + + @Override + public @Nullable String bungee_name() { + return "local-" + role().name().toLowerCase(); + } + + @Override + public int priority() { + return 0; + } + + @Override + public String ip() { + return minecraftServer.getAddress().getHostString(); + } + + @Override + public @Nullable Instant died_at() { + return null; + } + + @Override + public boolean dead() { + return false; + } + + @Override + public boolean alive() { + return true; + } + + @Override + public boolean dns_enabled() { + return false; + } + + @Override + public @Nullable Instant dns_toggled_at() { + return null; + } + + @Override + public String family() { + return role().name().toLowerCase(); + } + + @Override + public ServerDoc.Network network() { + return ServerDoc.Network.PUBLIC; + } + + @Override + public Set realms() { + return Collections.singleton("global"); + } + + @Override + public String name() { + switch(role()) { + case BUNGEE: return "BungeeCord"; + case LOBBY: return "Lobby"; + case PGM: return "PGM"; + default: return "Server"; + } + } + + @Override + public @Nullable String description() { + return null; + } + + @Override + public @Nullable String game_id() { + return null; + } + + @Override + public @Nullable String arena_id() { + return null; + } + + @Override + public ServerDoc.Visibility visibility() { + return ServerDoc.Visibility.PUBLIC; + } + + @Override + public ServerDoc.Visibility startup_visibility() { + return null; + } + + @Override + public String settings_profile() { + return "public"; + } + + @Override + public Map operators() { + final ImmutableMap.Builder ops = ImmutableMap.builder(); + for(OfflinePlayer op : minecraftServer.getOperators()) { + ops.put(op.getUniqueId(), op.getLastKnownName().orElse("Player")); + } + return ops.build(); + } + + @Override + public @Nullable team.Team team() { + return null; + } + + @Override + public Set participant_uuids() { + return Collections.emptySet(); + } + + @Override + public Map participant_permissions() { + return Collections.emptyMap(); + } + + @Override + public Map observer_permissions() { + return Collections.emptyMap(); + } + + @Override + public Map mapmaker_permissions() { + return Collections.emptyMap(); + } + + @Override + public boolean whitelist_enabled() { + return minecraftServer.hasWhitelist(); + } + + @Override + public boolean waiting_room() { + return false; + } + + @Override + public @Nullable String resource_pack_url() { + return null; + } + + @Override + public @Nullable String resource_pack_sha1() { + return null; + } + + @Override + public boolean resource_pack_fast_update() { + return false; + } + + @Override + public Map fake_usernames() { + return Collections.emptyMap(); + } + + @Override + public List banners() { + return Collections.emptyList(); + } + + @Override + public int max_players() { + return minecraftServer.getMaxPlayers(); + } + + @Override + public boolean running() { + return true; + } + + @Override + public boolean online() { + return true; + } + + @Override + public @Nullable Instant restart_queued_at() { + return null; + } + + @Override + public @Nullable String restart_reason() { + return null; + } + + @Override + public int num_online() { + return minecraftServer.getOnlinePlayers().size(); + } + + @Override + public int num_observing() { + return status != null ? status.num_observing() : 0; + } + + @Override + public int num_participating() { + return matchStatus != null ? matchStatus.num_participating() : 0; + } + + @Override + public @Nullable MatchDoc current_match() { + return matchStatus != null ? matchStatus.current_match() : null; + } + + @Override + public @Nullable MapDoc next_map() { + return matchStatus != null ? matchStatus.next_map() : null; + } + + @Override + public Set queued_mutations() { + return mutations != null ? mutations.queued_mutations() : Collections.emptySet(); + } +} diff --git a/API/minecraft/src/main/java/tc/oc/api/minecraft/servers/LocalServerReconfigureEvent.java b/API/minecraft/src/main/java/tc/oc/api/minecraft/servers/LocalServerReconfigureEvent.java new file mode 100644 index 0000000..b8a981f --- /dev/null +++ b/API/minecraft/src/main/java/tc/oc/api/minecraft/servers/LocalServerReconfigureEvent.java @@ -0,0 +1,28 @@ +package tc.oc.api.minecraft.servers; + +import javax.annotation.Nullable; + +import tc.oc.api.docs.Server; +import tc.oc.api.minecraft.MinecraftService; + +/** + * Fired by {@link MinecraftService} when the local server document changes. + */ +public class LocalServerReconfigureEvent { + + protected final @Nullable Server oldConfig; + protected final Server newConfig; + + public LocalServerReconfigureEvent(@Nullable Server oldConfig, Server newConfig) { + this.oldConfig = oldConfig; + this.newConfig = newConfig; + } + + public @Nullable Server getOldConfig() { + return oldConfig; + } + + public Server getNewConfig() { + return newConfig; + } +} diff --git a/API/minecraft/src/main/java/tc/oc/api/minecraft/servers/LocalServerService.java b/API/minecraft/src/main/java/tc/oc/api/minecraft/servers/LocalServerService.java new file mode 100644 index 0000000..6551ffe --- /dev/null +++ b/API/minecraft/src/main/java/tc/oc/api/minecraft/servers/LocalServerService.java @@ -0,0 +1,57 @@ +package tc.oc.api.minecraft.servers; + +import java.util.Collection; +import java.util.Collections; +import javax.inject.Inject; +import javax.inject.Singleton; + +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import tc.oc.api.docs.Server; +import tc.oc.api.docs.virtual.ServerDoc; +import tc.oc.api.message.types.FindMultiRequest; +import tc.oc.api.message.types.FindMultiResponse; +import tc.oc.api.message.types.FindRequest; +import tc.oc.api.message.types.PartialModelUpdate; +import tc.oc.api.message.types.UpdateMultiRequest; +import tc.oc.api.message.types.UpdateMultiResponse; +import tc.oc.api.model.NullModelService; +import tc.oc.api.servers.BungeeMetricRequest; +import tc.oc.api.servers.ServerService; + +@Singleton +public class LocalServerService extends NullModelService implements ServerService { + + @Inject private LocalServerDocument document; + + @Override + public ListenableFuture doBungeeMetric(BungeeMetricRequest request) { + return Futures.immediateFuture(null); + } + + @Override + public ListenableFuture> find(FindRequest request) { + if(request instanceof FindMultiRequest) { + final Collection ids = ((FindMultiRequest) request).ids(); + return Futures.immediateFuture(() -> ids.contains(document._id()) ? Collections.singletonList(document) + : Collections.emptyList()); + } else { + return Futures.immediateFuture(() -> Collections.singletonList(document)); + } + } + + @Override + public ListenableFuture update(String id, PartialModelUpdate request) { + if(document.equals(request.document())) { + document.update(request.document()); + return Futures.immediateFuture(document); + } + return super.update(id, request); + } + + @Override + public ListenableFuture updateMulti(UpdateMultiRequest request) { + request.documents().forEach(this::update); + return super.updateMulti(request); + } +} diff --git a/API/minecraft/src/main/java/tc/oc/api/minecraft/servers/MinecraftServersManifest.java b/API/minecraft/src/main/java/tc/oc/api/minecraft/servers/MinecraftServersManifest.java new file mode 100644 index 0000000..a33d7f1 --- /dev/null +++ b/API/minecraft/src/main/java/tc/oc/api/minecraft/servers/MinecraftServersManifest.java @@ -0,0 +1,15 @@ +package tc.oc.api.minecraft.servers; + +import tc.oc.api.servers.ServerService; +import tc.oc.commons.core.inject.HybridManifest; + +public class MinecraftServersManifest extends HybridManifest { + @Override + protected void configure() { + bindAndExpose(StartupServerDocument.class); + bindAndExpose(LocalServerDocument.class); + + publicBinder().forOptional(ServerService.class) + .setDefault().to(LocalServerService.class); + } +} diff --git a/API/minecraft/src/main/java/tc/oc/api/minecraft/servers/StartupServerDocument.java b/API/minecraft/src/main/java/tc/oc/api/minecraft/servers/StartupServerDocument.java new file mode 100644 index 0000000..d6cff41 --- /dev/null +++ b/API/minecraft/src/main/java/tc/oc/api/minecraft/servers/StartupServerDocument.java @@ -0,0 +1,73 @@ +package tc.oc.api.minecraft.servers; + +import java.io.FileNotFoundException; +import java.nio.file.Path; +import java.util.Map; +import java.util.Set; +import java.util.logging.Logger; +import javax.annotation.Nullable; +import javax.inject.Inject; +import javax.inject.Singleton; + +import com.google.common.base.Charsets; +import com.google.common.collect.ImmutableMap; +import com.google.common.io.Files; +import com.google.gson.Gson; +import tc.oc.api.docs.virtual.DeployInfo; +import tc.oc.api.docs.virtual.ServerDoc; +import tc.oc.commons.core.logging.Loggers; +import tc.oc.commons.core.util.Lazy; +import tc.oc.minecraft.api.plugin.PluginFinder; +import tc.oc.minecraft.api.server.LocalServer; + +@Singleton +public class StartupServerDocument implements ServerDoc.Startup { + + @Inject private Gson gson; + @Inject private LocalServer minecraftServer; + @Inject private PluginFinder pluginFinder; + + private Logger logger; + @Inject void init(Loggers loggers) { + logger = loggers.get(getClass()); + } + + private final Lazy> pluginVersions = Lazy.from(() -> { + final ImmutableMap.Builder builder = ImmutableMap.builder(); + builder.put(minecraftServer.getName(), minecraftServer.getVersion()); + pluginFinder.getAllPlugins().forEach( + plugin -> builder.put(plugin.getDescription().getName(), plugin.getDescription().getVersion()) + ); + return builder.build(); + }); + + private final Lazy deployInfo = Lazy.from(() -> { + final Path file = minecraftServer.getRootPath().resolve("deploy.json"); + try { + return gson.fromJson(Files.newReader(file.toFile(), Charsets.UTF_8), DeployInfo.class); + } catch(FileNotFoundException e) { + logger.warning("Missing " + file); + return null; + } + }); + + @Override public boolean online() { + return true; + } + + @Override public Integer current_port() { + return minecraftServer.getAddress().getPort(); + } + + @Override public @Nullable DeployInfo deploy_info() { + return deployInfo.get(); + } + + @Override public Map plugin_versions() { + return pluginVersions.get(); + } + + @Override public Set protocol_versions() { + return minecraftServer.getProtocolVersions(); + } +} diff --git a/API/minecraft/src/main/java/tc/oc/api/minecraft/sessions/LocalSessionFactory.java b/API/minecraft/src/main/java/tc/oc/api/minecraft/sessions/LocalSessionFactory.java new file mode 100644 index 0000000..53433fa --- /dev/null +++ b/API/minecraft/src/main/java/tc/oc/api/minecraft/sessions/LocalSessionFactory.java @@ -0,0 +1,75 @@ +package tc.oc.api.minecraft.sessions; + +import java.net.InetAddress; +import java.time.Instant; +import java.util.UUID; +import javax.annotation.Nullable; +import javax.inject.Inject; +import javax.inject.Singleton; + +import tc.oc.api.docs.PlayerId; +import tc.oc.api.docs.Server; +import tc.oc.api.docs.Session; +import tc.oc.api.docs.UserId; +import tc.oc.api.minecraft.users.UserStore; +import tc.oc.minecraft.api.entity.Player; + +@Singleton +public class LocalSessionFactory { + + @Inject Server localServer; + @Inject UserStore userStore; + + public Session newSession(UserId userId, InetAddress ip) { + final String id = UUID.randomUUID().toString(); + final Instant start = Instant.now(); + final PlayerId playerId = userStore.playerId(userId); + + return new Session() { + @Override + public String _id() { + return id; + } + + @Override + public String family_id() { + return localServer.family(); + } + + @Override + public String server_id() { + return localServer._id(); + } + + @Override + public PlayerId user() { + return playerId; + } + + @Override + public @Nullable String nickname() { + return null; + } + + @Override + public @Nullable String nickname_lower() { + return null; + } + + @Override + public String ip() { + return ip.getHostAddress(); + } + + @Override + public Instant start() { + return start; + } + + @Override + public @Nullable Instant end() { + return null; + } + }; + } +} diff --git a/API/minecraft/src/main/java/tc/oc/api/minecraft/sessions/LocalSessionService.java b/API/minecraft/src/main/java/tc/oc/api/minecraft/sessions/LocalSessionService.java new file mode 100644 index 0000000..fdb5f00 --- /dev/null +++ b/API/minecraft/src/main/java/tc/oc/api/minecraft/sessions/LocalSessionService.java @@ -0,0 +1,56 @@ +package tc.oc.api.minecraft.sessions; + +import java.util.Collections; +import javax.inject.Inject; +import javax.inject.Singleton; + +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import tc.oc.api.docs.Session; +import tc.oc.api.docs.UserId; +import tc.oc.api.docs.virtual.ServerDoc; +import tc.oc.api.docs.virtual.SessionDoc; +import tc.oc.api.exceptions.NotFound; +import tc.oc.api.message.types.FindMultiResponse; +import tc.oc.api.minecraft.users.OnlinePlayers; +import tc.oc.api.minecraft.users.UserStore; +import tc.oc.api.model.NullModelService; +import tc.oc.api.sessions.SessionService; +import tc.oc.api.sessions.SessionStartRequest; +import tc.oc.minecraft.api.entity.Player; + +@Singleton +public class LocalSessionService extends NullModelService implements SessionService { + + @Inject private OnlinePlayers onlinePlayers; + @Inject private UserStore userStore; + @Inject private LocalSessionFactory factory; + + @Override + public ListenableFuture start(SessionStartRequest request) { + return Futures.immediateFuture(factory.newSession(request::player_id, request.ip())); + } + + @Override + public ListenableFuture finish(Session session) { + return Futures.immediateFuture(null); + } + + @Override + public ListenableFuture online(UserId userId) { + return onlinePlayers.byUserId(userId) + .flatMap(userStore::session) + .map(Futures::immediateFuture) + .orElseGet(() -> Futures.immediateFailedFuture(new NotFound())); + } + + @Override + public ListenableFuture> friends(UserId player) { + return Futures.immediateFuture(Collections::emptyList); + } + + @Override + public ListenableFuture> staff(ServerDoc.Network network, boolean disguised) { + return Futures.immediateFuture(Collections::emptyList); + } +} diff --git a/API/minecraft/src/main/java/tc/oc/api/minecraft/sessions/MinecraftSessionsManifest.java b/API/minecraft/src/main/java/tc/oc/api/minecraft/sessions/MinecraftSessionsManifest.java new file mode 100644 index 0000000..9466608 --- /dev/null +++ b/API/minecraft/src/main/java/tc/oc/api/minecraft/sessions/MinecraftSessionsManifest.java @@ -0,0 +1,15 @@ +package tc.oc.api.minecraft.sessions; + +import tc.oc.api.sessions.SessionService; +import tc.oc.commons.core.inject.HybridManifest; + +public class MinecraftSessionsManifest extends HybridManifest { + + @Override + protected void configure() { + bindAndExpose(LocalSessionFactory.class); + + publicBinder().forOptional(SessionService.class) + .setDefault().to(LocalSessionService.class); + } +} diff --git a/API/minecraft/src/main/java/tc/oc/api/minecraft/users/LocalUserDocument.java b/API/minecraft/src/main/java/tc/oc/api/minecraft/users/LocalUserDocument.java new file mode 100644 index 0000000..2faf4ca --- /dev/null +++ b/API/minecraft/src/main/java/tc/oc/api/minecraft/users/LocalUserDocument.java @@ -0,0 +1,132 @@ +package tc.oc.api.minecraft.users; + +import java.time.Instant; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import javax.annotation.Nullable; + +import com.google.common.collect.ImmutableMap; +import tc.oc.api.docs.PlayerId; +import tc.oc.api.docs.User; +import tc.oc.api.docs.virtual.UserDoc; +import tc.oc.api.util.Permissions; +import tc.oc.minecraft.api.entity.OfflinePlayer; + +public class LocalUserDocument implements User { + + private final OfflinePlayer player; + + public LocalUserDocument(OfflinePlayer player) { + this.player = player; + } + + @Override + public String _id() { + return player.getUniqueId().toString(); + } + + @Override + public @Nullable String nickname() { + return null; + } + + @Override + public @Nullable String mc_locale() { + return null; + } + + @Override + public String player_id() { + return _id(); + } + + @Override + public String username() { + return player.getLastKnownName().orElse(""); + } + + @Override + public UUID uuid() { + return player.getUniqueId(); + } + + @Override + public List minecraft_flair() { + return Collections.emptyList(); + } + + @Override + public List trophy_ids() { + return Collections.emptyList(); + } + + @Override + public @Nullable Instant requested_tnt_license_at() { + return null; + } + + @Override + public @Nullable Instant granted_tnt_license_at() { + return null; + } + + @Override + public List tnt_license_kills() { + return Collections.emptyList(); + } + + @Override + public int raindrops() { + return 0; + } + + @Override + public String mc_last_sign_in_ip() { + return player.onlinePlayer() + .map(p -> p.getAddress().getHostString()) + .orElse(""); + } + + @Override + public @Nullable Date trial_expires_at() { + return null; + } + + @Override + public Map> mc_permissions_by_realm() { + return ImmutableMap.of( + "global", ImmutableMap.of( + Permissions.LOGIN, true + ) + ); + } + + @Override + public Map> mc_settings_by_profile() { + return Collections.emptyMap(); + } + + @Override + public Map classes() { + return Collections.emptyMap(); + } + + @Override + public Set friends() { + return Collections.emptySet(); + } + + @Override + public Map> recent_match_joins_by_family_id() { + return Collections.emptyMap(); + } + + @Override + public int enemy_kills() { + return 0; + } +} diff --git a/API/minecraft/src/main/java/tc/oc/api/minecraft/users/LocalUserService.java b/API/minecraft/src/main/java/tc/oc/api/minecraft/users/LocalUserService.java new file mode 100644 index 0000000..2bdfc39 --- /dev/null +++ b/API/minecraft/src/main/java/tc/oc/api/minecraft/users/LocalUserService.java @@ -0,0 +1,151 @@ +package tc.oc.api.minecraft.users; + +import java.util.Collections; +import java.util.List; +import java.util.UUID; +import javax.annotation.Nullable; +import javax.inject.Inject; +import javax.inject.Singleton; + +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import tc.oc.api.docs.Punishment; +import tc.oc.api.docs.Session; +import tc.oc.api.docs.User; +import tc.oc.api.docs.UserId; +import tc.oc.api.docs.Whisper; +import tc.oc.api.docs.virtual.UserDoc; +import tc.oc.api.exceptions.NotFound; +import tc.oc.api.minecraft.sessions.LocalSessionFactory; +import tc.oc.api.model.NullModelService; +import tc.oc.api.users.ChangeClassRequest; +import tc.oc.api.users.ChangeSettingRequest; +import tc.oc.api.users.CreditRaindropsRequest; +import tc.oc.api.users.LoginRequest; +import tc.oc.api.users.LoginResponse; +import tc.oc.api.users.LogoutRequest; +import tc.oc.api.users.PurchaseGizmoRequest; +import tc.oc.api.users.UserSearchRequest; +import tc.oc.api.users.UserSearchResponse; +import tc.oc.api.users.UserService; +import tc.oc.api.users.UserUpdateResponse; +import tc.oc.commons.core.concurrent.FutureUtils; +import tc.oc.minecraft.api.entity.OfflinePlayer; +import tc.oc.minecraft.api.server.LocalServer; + +@Singleton +public class LocalUserService extends NullModelService implements UserService { + + @Inject private LocalServer minecraftServer; + @Inject private LocalSessionFactory sessionFactory; + + @Override + public ListenableFuture find(UserId userId) { + return Futures.immediateFuture(new LocalUserDocument(minecraftServer.getOfflinePlayer(UUID.fromString(userId.player_id())))); + } + + @Override + public ListenableFuture search(UserSearchRequest request) { + for(OfflinePlayer player : minecraftServer.getSavedPlayers()) { + if(player.getLastKnownName() + .filter(name -> name.equalsIgnoreCase(request.username)) + .isPresent()) { + return Futures.immediateFuture(new UserSearchResponse(new LocalUserDocument(player), + player.isOnline(), + false, + null, + null)); + } + } + return Futures.immediateFailedFuture(new NotFound("No user named '" + request.username + "'", null)); + } + + @Override + public ListenableFuture login(LoginRequest request) { + final User user = new LocalUserDocument(minecraftServer.getOfflinePlayer(request.uuid)); + final Session session = request.start_session ? sessionFactory.newSession(user, request.ip) + : null; + + return Futures.immediateFuture(new LoginResponse() { + @Override + public @Nullable String kick() { + return null; + } + + @Override + public @Nullable String message() { + return null; + } + + @Override + public @Nullable String route_to_server() { + return null; + } + + @Override + public User user() { + return user; + } + + @Override + public @Nullable Session session() { + return session; + } + + @Override + public @Nullable Punishment punishment() { + return null; + } + + @Override + public List whispers() { + return Collections.emptyList(); + } + + @Override + public int unread_appeal_count() { + return 0; + } + }); + } + + @Override + public ListenableFuture logout(LogoutRequest request) { + return Futures.immediateFuture(null); + } + + @Override + public ListenableFuture creditRaindrops(UserId userId, CreditRaindropsRequest request) { + return FutureUtils.mapSync(find(userId), user -> new UserUpdateResponse() { + @Override + public boolean success() { + return true; + } + + @Override + public User user() { + return user; + } + }); + } + + @Override + public ListenableFuture purchaseGizmo(UserId userId, PurchaseGizmoRequest request) { + return find(userId); + } + + @Override + public ListenableFuture update(UserId userId, T update) { + return find(userId); + } + + @Override + public ListenableFuture changeSetting(UserId userId, ChangeSettingRequest request) { + return find(userId); + } + + @Override + public ListenableFuture changeClass(UserId userId, ChangeClassRequest request) { + return find(userId); + } +} diff --git a/API/minecraft/src/main/java/tc/oc/api/minecraft/users/MinecraftUsersManifest.java b/API/minecraft/src/main/java/tc/oc/api/minecraft/users/MinecraftUsersManifest.java new file mode 100644 index 0000000..3d60b6c --- /dev/null +++ b/API/minecraft/src/main/java/tc/oc/api/minecraft/users/MinecraftUsersManifest.java @@ -0,0 +1,21 @@ +package tc.oc.api.minecraft.users; + +import com.google.inject.TypeLiteral; +import tc.oc.api.users.UserService; +import tc.oc.commons.core.inject.HybridManifest; +import tc.oc.minecraft.api.entity.Player; + +public class MinecraftUsersManifest extends HybridManifest { + + @Override + protected void configure() { + publicBinder().forOptional(UserService.class) + .setDefault().to(LocalUserService.class); + + bindAndExpose(new TypeLiteral>(){}) + .to((Class) UserStore.class); + + bindAndExpose(new TypeLiteral>(){}) + .to((Class) OnlinePlayers.class); + } +} diff --git a/API/minecraft/src/main/java/tc/oc/api/minecraft/users/OnlinePlayers.java b/API/minecraft/src/main/java/tc/oc/api/minecraft/users/OnlinePlayers.java new file mode 100644 index 0000000..faac518 --- /dev/null +++ b/API/minecraft/src/main/java/tc/oc/api/minecraft/users/OnlinePlayers.java @@ -0,0 +1,65 @@ +package tc.oc.api.minecraft.users; + +import java.util.Collection; +import java.util.Optional; +import java.util.UUID; +import java.util.stream.Stream; +import javax.annotation.Nullable; + +import tc.oc.api.docs.PlayerId; +import tc.oc.api.docs.User; +import tc.oc.api.docs.UserId; +import tc.oc.minecraft.api.entity.Player; + +/** + * Enumerate currently online players, or look them up by various criteria. + * + * The {@link #find} methods return null if the user is not online, + * while the {@link #get} methods throw {@link IllegalStateException}. + */ +public interface OnlinePlayers

{ + + Collection

all(); + + default Stream

stream() { + return all().stream(); + } + + default int count() { + return all().size(); + } + + @Nullable P find(String name); + @Nullable P find(UUID uuid); + @Nullable P find(UserId userId); + + default Optional

byName(String name) { + return Optional.ofNullable(find(name)); + } + + default Optional

byUuid(UUID uuid) { + return Optional.ofNullable(find(uuid)); + } + + default Optional

byUserId(UserId userId) { + return Optional.ofNullable(find(userId)); + } + + default P get(String name) { + final P player = find(name); + if(player == null) throw new IllegalStateException("Player with username " + name + " is not online"); + return player; + } + + default P get(UUID uuid) { + final P player = find(uuid); + if(player == null) throw new IllegalStateException("Player with UUID " + uuid + " is not online"); + return player; + } + + default P get(UserId userId) { + final P player = find(userId); + if(player == null) throw new IllegalStateException("Player with UserId " + userId.player_id() + " is not online"); + return player; + } +} diff --git a/API/minecraft/src/main/java/tc/oc/api/minecraft/users/UserStore.java b/API/minecraft/src/main/java/tc/oc/api/minecraft/users/UserStore.java new file mode 100644 index 0000000..929c326 --- /dev/null +++ b/API/minecraft/src/main/java/tc/oc/api/minecraft/users/UserStore.java @@ -0,0 +1,246 @@ +package tc.oc.api.minecraft.users; + +import java.lang.ref.WeakReference; +import java.util.Collection; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.WeakHashMap; +import java.util.concurrent.Executor; +import javax.annotation.Nullable; +import javax.inject.Inject; +import javax.inject.Provider; + +import tc.oc.api.docs.PlayerId; +import tc.oc.api.docs.Session; +import tc.oc.api.docs.SimplePlayerId; +import tc.oc.api.docs.User; +import tc.oc.api.docs.UserId; +import tc.oc.api.model.ModelSync; +import tc.oc.commons.core.util.ProxyUtils; +import tc.oc.minecraft.api.entity.Player; +import tc.oc.minecraft.api.server.LocalServer; + +import static com.google.common.base.Preconditions.checkNotNull; + +public abstract class UserStore

implements OnlinePlayers

{ + + @Inject private LocalServer server; + @Inject private @ModelSync Executor modelSync; + + private final Map users = new WeakHashMap<>(); + private final Map sessions = new WeakHashMap<>(); + + // Keys of this map are the actual proxies + private final WeakHashMap proxies = new WeakHashMap<>(); + + private static class EverfreshUser implements Provider { + User real; + WeakReference proxy; + + @Override + public User get() { + return real; + } + } + + @Override + public Collection

all() { + return (Collection

) server.getOnlinePlayers(); + } + + @Override + public @Nullable P find(String name) { + return (P) server.getPlayerExact(name); + } + + @Override + public @Nullable P find(UUID uuid) { + return (P) server.getPlayer(uuid); + } + + @Override + public @Nullable P find(UserId userId) { + if(userId instanceof PlayerId) { + return find(((PlayerId) userId).username()); + } else { + final User user = tryUser(userId); + return user == null ? null : find(user.username()); + } + } + + protected void updateUser(Player player, @Nullable User before, @Nullable User after) { + if(after == null) { + users.remove(player); + proxies.remove(before); + } else { + users.put(player, after); + + final EverfreshUser proxy = proxies.get(after); + if(proxy != null) { + proxy.real = after; + } + } + } + + public void addUser(Player player, User user) { + checkNotNull(user); + updateUser(player, null, user); + } + + public @Nullable User replaceUser(Player player, User user) { + checkNotNull(player); + checkNotNull(user); + final User old = users.get(player); + if(old != null) { + updateUser(player, old, user); + } + return old; + } + + public void handleUpdate(User replacement) { + modelSync.execute(() -> { + final P player = find(replacement.uuid()); + if(player != null) { + replaceUser(player, replacement); + } + }); + } + + public @Nullable User removeUser(Player player) { + final User old = users.get(player); + if(old != null) { + updateUser(player, old, null); + } + return old; + } + + public boolean hasUser(Player player) { + return users.containsKey(player); + } + + public Optional user(UserId userId) { + return Optional.ofNullable(tryUser(userId)); + } + + public Optional user(Player player) { + return Optional.ofNullable(tryUser(player)); + } + + public @Nullable User tryUser(UserId userId) { + // If the argument is a User already, return it + if(userId instanceof User) { + return (User) userId; + } + + // Search the store for a full User + for(User user : users.values()) { + // User extends UserId, so they will compare equal if they are the same user + if(userId.equals(user)) return user; + } + + return null; + } + + public @Nullable User tryUser(Player player) { + return users.get(player); + } + + public User getUser(UserId userId) { + final User user = tryUser(userId); + if(user == null) { + throw new IllegalStateException("User " + userId + " has no cached user document"); + } + return user; + } + + public User getUser(Player player) { + final User doc = tryUser(player); + if(doc == null) { + throw new IllegalStateException("Player " + player + " has no cached user document"); + } + return doc; + } + + public PlayerId playerId(UserId userId) { + if(userId instanceof PlayerId) { + return SimplePlayerId.copyOf((PlayerId) userId); + } + return SimplePlayerId.copyOf(getUser(userId)); + } + + /** + * Return a light-weight {@link PlayerId} for the given player. + */ + public PlayerId playerId(Player player) { + return SimplePlayerId.copyOf(getUser(player)); + } + + /** + * Return a dynamic {@link User} instance that stays in sync with the latest + * version of the document. + * + * The given player must be online when this method is called, but the returned + * document can be used even after they have disconnected, it just won't update. + * If they reconnect, the old document will resume updating. + * + * These proxies are stored in a weak collection, so don't hold references to + * them for any longer than necessary. + */ + public User getEverfreshUser(Player player) { + return getEverfreshUser(getUser(player)); + } + + public User getEverfreshUser(UserId user) { + // Look for an existing proxy + EverfreshUser provider = proxies.get(user); + if(provider != null) { + final User fake = provider.proxy.get(); + if(fake != null) { + // If the proxy is still available, return it + return fake; + } else { + // If the proxy was garbage collected, remove + // the entry from the map, and create a new one. + proxies.remove(user); + } + } + + // Create a new provider and store the latest User document in it. + // Don't store the document passed to the method, because we don't + // know how old it is, or whether or not it is a proxy itself. + provider = new EverfreshUser(); + provider.real = getUser(get(user)); + + // Store the proxy User in the provider, so that we can look it up later. + // There is no fast way to get a key from a map, so we have to store + // a copy of it in the value. It has to be a weak reference or the entry + // in the weak map would never be released. + final User proxy = ProxyUtils.newProviderProxy(User.class, provider); + provider.proxy = new WeakReference<>(proxy); + + // Store the provider with the proxy User as the (weak) key + proxies.put(proxy, provider); + return proxy; + } + + public void setSession(Player player, Session session) { + sessions.put(player, checkNotNull(session)); + } + + public @Nullable Session removeSession(Player player) { + return sessions.remove(player); + } + + public Optional session(Player player) { + return Optional.ofNullable(sessions.get(player)); + } + + public Session getSession(Player player) { + Session session = sessions.get(player); + if(session == null) { + throw new IllegalStateException("Player " + player.getName() + " has no session"); + } + return session; + } +} diff --git a/API/ocn/pom.xml b/API/ocn/pom.xml new file mode 100644 index 0000000..5616e4d --- /dev/null +++ b/API/ocn/pom.xml @@ -0,0 +1,49 @@ + + 4.0.0 + + + tc.oc + api-parent + ../pom.xml + 1.11-SNAPSHOT + + + api-ocn + jar + API-OCN + API backend for the Overcast Network + + + + tc.oc + api-minecraft + ${project.version} + + + + + + + . + true + ${basedir}/src/main/resources/ + + + + + + + pl.project13.maven + git-commit-id-plugin + 2.1.0 + + + + revision + + + + + + + diff --git a/API/ocn/src/main/java/tc/oc/api/ocn/OCNApiManifest.java b/API/ocn/src/main/java/tc/oc/api/ocn/OCNApiManifest.java new file mode 100644 index 0000000..f798297 --- /dev/null +++ b/API/ocn/src/main/java/tc/oc/api/ocn/OCNApiManifest.java @@ -0,0 +1,16 @@ +package tc.oc.api.ocn; + +import tc.oc.api.http.HttpManifest; +import tc.oc.api.minecraft.queue.MinecraftQueueManifest; +import tc.oc.api.model.ModelBinders; +import tc.oc.commons.core.inject.HybridManifest; + +public class OCNApiManifest extends HybridManifest implements ModelBinders { + + @Override + protected void configure() { + install(new OCNModelsManifest()); + install(new MinecraftQueueManifest()); + install(new HttpManifest()); + } +} diff --git a/API/ocn/src/main/java/tc/oc/api/ocn/OCNEngagementService.java b/API/ocn/src/main/java/tc/oc/api/ocn/OCNEngagementService.java new file mode 100644 index 0000000..63a49e2 --- /dev/null +++ b/API/ocn/src/main/java/tc/oc/api/ocn/OCNEngagementService.java @@ -0,0 +1,21 @@ +package tc.oc.api.ocn; + +import java.util.Collection; +import javax.inject.Inject; + +import com.google.common.util.concurrent.ListenableFuture; +import tc.oc.api.docs.virtual.EngagementDoc; +import tc.oc.api.engagement.EngagementService; +import tc.oc.api.engagement.EngagementUpdateRequest; +import tc.oc.api.message.types.Reply; +import tc.oc.api.queue.Transaction; + +class OCNEngagementService implements EngagementService { + + @Inject private Transaction.Factory transactionFactory; + + @Override + public ListenableFuture updateMulti(Collection engagements) { + return transactionFactory.request(new EngagementUpdateRequest(engagements)); + } +} diff --git a/API/ocn/src/main/java/tc/oc/api/ocn/OCNMapService.java b/API/ocn/src/main/java/tc/oc/api/ocn/OCNMapService.java new file mode 100644 index 0000000..ee4c9a4 --- /dev/null +++ b/API/ocn/src/main/java/tc/oc/api/ocn/OCNMapService.java @@ -0,0 +1,30 @@ +package tc.oc.api.ocn; + +import java.util.Collection; +import javax.inject.Singleton; + +import com.google.common.util.concurrent.ListenableFuture; +import tc.oc.api.docs.MapRating; +import tc.oc.api.docs.virtual.MapDoc; +import tc.oc.api.http.HttpOption; +import tc.oc.api.maps.MapRatingsRequest; +import tc.oc.api.maps.MapRatingsResponse; +import tc.oc.api.maps.MapService; +import tc.oc.api.maps.MapUpdateMultiResponse; +import tc.oc.api.model.HttpModelService; + +@Singleton +class OCNMapService extends HttpModelService implements MapService { + + public ListenableFuture rate(MapRating rating) { + return this.client().post(memberUri(rating.map_id, "rate"), rating, Object.class, HttpOption.INFINITE_RETRY); + } + + public ListenableFuture getRatings(MapRatingsRequest request) { + return this.client().post(memberUri(request.map_id, "get_ratings"), request, MapRatingsResponse.class, HttpOption.INFINITE_RETRY); + } + + public ListenableFuture updateMapsAndLookupAuthors(Collection maps) { + return updateMulti(maps, MapUpdateMultiResponse.class); + } +} diff --git a/API/ocn/src/main/java/tc/oc/api/ocn/OCNModelsManifest.java b/API/ocn/src/main/java/tc/oc/api/ocn/OCNModelsManifest.java new file mode 100644 index 0000000..2559426 --- /dev/null +++ b/API/ocn/src/main/java/tc/oc/api/ocn/OCNModelsManifest.java @@ -0,0 +1,78 @@ +package tc.oc.api.ocn; + +import tc.oc.api.docs.Arena; +import tc.oc.api.docs.Death; +import tc.oc.api.docs.Game; +import tc.oc.api.docs.Objective; +import tc.oc.api.docs.Participation; +import tc.oc.api.docs.Punishment; +import tc.oc.api.docs.Report; +import tc.oc.api.docs.Trophy; +import tc.oc.api.docs.virtual.DeathDoc; +import tc.oc.api.docs.virtual.MatchDoc; +import tc.oc.api.docs.virtual.PunishmentDoc; +import tc.oc.api.docs.virtual.ReportDoc; +import tc.oc.api.engagement.EngagementService; +import tc.oc.api.games.TicketService; +import tc.oc.api.maps.MapService; +import tc.oc.api.model.ModelBinders; +import tc.oc.api.servers.ServerService; +import tc.oc.api.sessions.SessionService; +import tc.oc.api.tourney.TournamentService; +import tc.oc.api.users.UserService; +import tc.oc.api.whispers.WhisperService; +import tc.oc.commons.core.inject.HybridManifest; +import tc.oc.commons.core.inject.Manifest; + +public class OCNModelsManifest extends HybridManifest implements ModelBinders { + + @Override + protected void configure() { + // Generic AMQP services + bindModel(Game.class, model -> { + model.queryService().setBinding().to(model.queueQueryService()); + }); + bindModel(Arena.class, model -> { + model.queryService().setBinding().to(model.queueQueryService()); + }); + bindModel(Trophy.class, model -> { + model.queryService().setBinding().to(model.queueQueryService()); + }); + + // Generic HTTP services + bindModel(Report.class, ReportDoc.Partial.class, model -> { + model.bindService().to(model.httpService()); + }); + bindModel(Punishment.class, PunishmentDoc.Partial.class, model -> { + model.bindService().to(model.httpService()); + }); + bindModel(MatchDoc.class, model -> { + model.bindService().to(model.httpService()); + }); + bindModel(Participation.Complete.class, Participation.Partial.class, model -> { + model.bindService().to(model.httpService()); + }); + bindModel(Death.class, DeathDoc.Partial.class, model -> { + model.bindService().to(model.httpService()); + }); + bindModel(Objective.class, model -> { + model.bindService().to(model.httpService()); + }); + + publicBinder().install(new Manifest() { + @Override protected void configure() { + // Specialized AMQP services + forOptional(EngagementService.class).setBinding().to(OCNEngagementService.class); + forOptional(TicketService.class).setBinding().to(OCNTicketService.class); + + // Specialized HTTP services + forOptional(MapService.class).setBinding().to(OCNMapService.class); + forOptional(ServerService.class).setBinding().to(OCNServerService.class); + forOptional(SessionService.class).setBinding().to(OCNSessionService.class); + forOptional(TournamentService.class).setBinding().to(OCNTournamentService.class); + forOptional(UserService.class).setBinding().to(OCNUserService.class); + forOptional(WhisperService.class).setBinding().to(OCNWhisperService.class); + } + }); + } +} diff --git a/API/ocn/src/main/java/tc/oc/api/ocn/OCNServerService.java b/API/ocn/src/main/java/tc/oc/api/ocn/OCNServerService.java new file mode 100644 index 0000000..12e1a4b --- /dev/null +++ b/API/ocn/src/main/java/tc/oc/api/ocn/OCNServerService.java @@ -0,0 +1,51 @@ +package tc.oc.api.ocn; + +import java.util.Collection; +import javax.inject.Inject; +import javax.inject.Singleton; + +import com.google.common.util.concurrent.ListenableFuture; +import tc.oc.api.docs.Server; +import tc.oc.api.docs.virtual.ServerDoc; +import tc.oc.api.http.HttpOption; +import tc.oc.api.message.types.FindMultiResponse; +import tc.oc.api.message.types.FindRequest; +import tc.oc.api.model.HttpModelService; +import tc.oc.api.queue.QueueQueryService; +import tc.oc.api.servers.BungeeMetricRequest; +import tc.oc.api.servers.ServerService; + +@Singleton +class OCNServerService extends HttpModelService implements ServerService { + + private final QueueQueryService queryService; + + @Inject OCNServerService(QueueQueryService queryService) { + this.queryService = queryService; + } + + @Override + public ListenableFuture doBungeeMetric(BungeeMetricRequest request) { + return this.client().post("/servers/metric", request, HttpOption.INFINITE_RETRY); + } + + @Override + public ListenableFuture> all() { + return queryService.all(); + } + + @Override + public ListenableFuture find(String id) { + return queryService.find(id); + } + + @Override + public ListenableFuture> find(FindRequest request) { + return queryService.find(request); + } + + @Override + public ListenableFuture> find(Collection ids) { + return queryService.find(ids); + } +} diff --git a/API/ocn/src/main/java/tc/oc/api/ocn/OCNSessionService.java b/API/ocn/src/main/java/tc/oc/api/ocn/OCNSessionService.java new file mode 100644 index 0000000..8609ea9 --- /dev/null +++ b/API/ocn/src/main/java/tc/oc/api/ocn/OCNSessionService.java @@ -0,0 +1,60 @@ +package tc.oc.api.ocn; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import com.damnhandy.uri.template.UriTemplate; +import com.google.common.util.concurrent.ListenableFuture; +import tc.oc.api.docs.Session; +import tc.oc.api.docs.UserId; +import tc.oc.api.docs.virtual.ServerDoc; +import tc.oc.api.docs.virtual.SessionDoc; +import tc.oc.api.http.HttpOption; +import tc.oc.api.http.QueryUri; +import tc.oc.api.message.types.FindMultiResponse; +import tc.oc.api.model.HttpModelService; +import tc.oc.api.model.ModelMeta; +import tc.oc.api.sessions.SessionService; +import tc.oc.api.sessions.SessionStartRequest; + +@Singleton +class OCNSessionService extends HttpModelService implements SessionService { + + @Inject private ModelMeta meta; + + @Override + public ListenableFuture start(SessionStartRequest request) { + return this.client().post(collectionUri("start"), request, Session.class, HttpOption.INFINITE_RETRY); + } + + @Override + public ListenableFuture finish(Session session) { + return client().post(memberUri(session, "finish"), null, Session.class, HttpOption.INFINITE_RETRY); + } + + @Override + public ListenableFuture online(UserId player) { + return client().get(UriTemplate.fromTemplate(collectionUri("online") + "/{player}").set("player", player.player_id()).expand(), + Session.class, + HttpOption.INFINITE_RETRY); + } + + @Override + public ListenableFuture> friends(UserId player) { + return client().get(UriTemplate.fromTemplate(collectionUri("friends") + "/{player}").set("player", player.player_id()).expand(), + meta.multiResponseType(), + HttpOption.INFINITE_RETRY); + } + + @Override + public ListenableFuture> staff(ServerDoc.Network network, boolean disguised) { + return client().get(new QueryUri(collectionUri()) + .put("network", network) + .put("staff", true) + .put("online", true) + .put("disguised", disguised) + .encode(), + meta.multiResponseType(), + HttpOption.INFINITE_RETRY); + } +} diff --git a/API/ocn/src/main/java/tc/oc/api/ocn/OCNTicketService.java b/API/ocn/src/main/java/tc/oc/api/ocn/OCNTicketService.java new file mode 100644 index 0000000..f33d70f --- /dev/null +++ b/API/ocn/src/main/java/tc/oc/api/ocn/OCNTicketService.java @@ -0,0 +1,28 @@ +package tc.oc.api.ocn; + +import javax.inject.Inject; + +import com.google.common.util.concurrent.ListenableFuture; +import tc.oc.api.docs.Ticket; +import tc.oc.api.games.TicketService; +import tc.oc.api.message.types.CycleRequest; +import tc.oc.api.message.types.CycleResponse; +import tc.oc.api.message.types.PlayGameRequest; +import tc.oc.api.message.types.Reply; +import tc.oc.api.queue.QueueQueryService; +import tc.oc.api.queue.Transaction; + +public class OCNTicketService extends QueueQueryService implements TicketService { + + @Inject private Transaction.Factory transactionFactory; + + @Override + public ListenableFuture requestPlay(PlayGameRequest request) { + return transactionFactory.request(request); + } + + @Override + public ListenableFuture requestCycle(CycleRequest request) { + return transactionFactory.request(request, CycleResponse.class); + } +} diff --git a/API/ocn/src/main/java/tc/oc/api/ocn/OCNTournamentService.java b/API/ocn/src/main/java/tc/oc/api/ocn/OCNTournamentService.java new file mode 100644 index 0000000..340e221 --- /dev/null +++ b/API/ocn/src/main/java/tc/oc/api/ocn/OCNTournamentService.java @@ -0,0 +1,54 @@ +package tc.oc.api.ocn; + +import javax.inject.Singleton; + +import com.damnhandy.uri.template.UriTemplate; +import com.google.common.collect.ImmutableMap; +import com.google.common.util.concurrent.ListenableFuture; +import tc.oc.api.docs.Entrant; +import tc.oc.api.docs.PlayerId; +import tc.oc.api.docs.Tournament; +import tc.oc.api.http.HttpOption; +import tc.oc.api.model.HttpQueryService; +import tc.oc.api.tourney.RecordMatchResponse; +import tc.oc.api.tourney.TournamentService; + +@Singleton +class OCNTournamentService extends HttpQueryService implements TournamentService { + + @Override + public ListenableFuture recordMatch(Tournament tournament, String matchId) { + return client().post(memberUri(tournament._id(), "record_match"), + ImmutableMap.of("match_id", matchId), + RecordMatchResponse.class, + HttpOption.INFINITE_RETRY); + } + + @Override + public ListenableFuture entrant(String tournamentId, String teamId) { + return client().get(UriTemplate.fromTemplate("/tournaments/{id}/entrants/{team_id}") + .set("id", tournamentId) + .set("team_id", teamId) + .expand(), + Entrant.class); + } + + private ListenableFuture entrantSearch(String tournamentId, String param, String value) { + return client().get(UriTemplate.fromTemplate("/tournaments/{id}/entrants?{param}={value}") + .set("id", tournamentId) + .set("param", param) + .set("value", value) + .expand(), + Entrant.class); + } + + @Override + public ListenableFuture entrantByTeamName(String tournamentId, String teamName) { + return entrantSearch(tournamentId, "team_name", teamName); + } + + @Override + public ListenableFuture entrantByMember(String tournamentId, PlayerId playerId) { + return entrantSearch(tournamentId, "member_id", playerId._id()); + } +} diff --git a/API/ocn/src/main/java/tc/oc/api/ocn/OCNUserService.java b/API/ocn/src/main/java/tc/oc/api/ocn/OCNUserService.java new file mode 100644 index 0000000..ea1b115 --- /dev/null +++ b/API/ocn/src/main/java/tc/oc/api/ocn/OCNUserService.java @@ -0,0 +1,113 @@ +package tc.oc.api.ocn; + +import java.util.UUID; +import javax.inject.Inject; +import javax.inject.Singleton; + +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import tc.oc.api.docs.User; +import tc.oc.api.docs.UserId; +import tc.oc.api.docs.virtual.ServerDoc; +import tc.oc.api.docs.virtual.UserDoc; +import tc.oc.api.http.HttpOption; +import tc.oc.api.message.types.PlayerTeleportRequest; +import tc.oc.api.minecraft.users.UserStore; +import tc.oc.api.model.HttpModelService; +import tc.oc.api.queue.Exchange; +import tc.oc.api.users.ChangeClassRequest; +import tc.oc.api.users.ChangeSettingRequest; +import tc.oc.api.users.CreditRaindropsRequest; +import tc.oc.api.users.LoginRequest; +import tc.oc.api.users.LoginResponse; +import tc.oc.api.users.LogoutRequest; +import tc.oc.api.users.PurchaseGizmoRequest; +import tc.oc.api.users.UserSearchRequest; +import tc.oc.api.users.UserSearchResponse; +import tc.oc.api.users.UserService; +import tc.oc.api.users.UserUpdateResponse; +import tc.oc.commons.core.concurrent.FutureUtils; +import tc.oc.minecraft.api.entity.Player; + +@Singleton +class OCNUserService extends HttpModelService implements UserService { + + @Inject private UserStore userStore; + @Inject private Exchange.Topic topic; + + protected String memberUri(UserId userId) { + return memberUri(userId.player_id()); + } + + protected String memberUri(UserId userId, String action) { + return memberUri(userId.player_id(), action); + } + + @Override + protected void handleUpdate(User doc) { + userStore.handleUpdate(doc); + } + + protected ListenableFuture handleUserUpdate(ListenableFuture future) { + return FutureUtils.peek(future, result -> { + if(result.success() && result.user() != null) { + handleUpdate(result.user()); + } + }); + } + + @Override + public ListenableFuture find(UserId userId) { + final User user = userStore.tryUser(userId); + if(user != null) { + return Futures.immediateFuture(user); + } else { + return find(userId.player_id()); + } + } + + @Override + public ListenableFuture search(UserSearchRequest request) { + return client().post(collectionUri("search"), request, UserSearchResponse.class, HttpOption.INFINITE_RETRY); + } + + @Override + public ListenableFuture login(LoginRequest request) { + return client().post(collectionUri("login"), request, LoginResponse.class, HttpOption.INFINITE_RETRY); + } + + @Override + public ListenableFuture logout(LogoutRequest request) { + return client().post(memberUri(request.player_id, "logout"), request, HttpOption.INFINITE_RETRY); + } + + @Override + public ListenableFuture creditRaindrops(UserId userId, CreditRaindropsRequest request) { + return handleUserUpdate(client().post(memberUri(userId, "credit_raindrops"), request, UserUpdateResponse.class, HttpOption.INFINITE_RETRY)); + } + + @Override + public ListenableFuture purchaseGizmo(UserId userId, PurchaseGizmoRequest request) { + return handleUpdate(client().post(memberUri(userId, "purchase_gizmo"), request, User.class, HttpOption.INFINITE_RETRY)); + } + + @Override + public ListenableFuture update(UserId userId, T update) { + return update(userId.player_id(), update); + } + + @Override + public ListenableFuture changeSetting(UserId userId, ChangeSettingRequest request) { + return handleUpdate(client().post(memberUri(userId, "change_setting"), request, User.class, HttpOption.INFINITE_RETRY)); + } + + @Override + public ListenableFuture changeClass(UserId userId, ChangeClassRequest request) { + return handleUpdate(client().post(memberUri(userId, "change_class"), request, User.class, HttpOption.INFINITE_RETRY)); + } + + @Override + public void requestTeleport(UUID travelerId, ServerDoc.Identity targetServer, UUID targetId) { + topic.publishAsync(new PlayerTeleportRequest(travelerId, targetServer, targetId)); + } +} diff --git a/API/ocn/src/main/java/tc/oc/api/ocn/OCNWhisperService.java b/API/ocn/src/main/java/tc/oc/api/ocn/OCNWhisperService.java new file mode 100644 index 0000000..dac2829 --- /dev/null +++ b/API/ocn/src/main/java/tc/oc/api/ocn/OCNWhisperService.java @@ -0,0 +1,25 @@ +package tc.oc.api.ocn; + +import javax.inject.Singleton; + +import com.damnhandy.uri.template.UriTemplate; +import com.google.common.util.concurrent.ListenableFuture; +import tc.oc.api.docs.PlayerId; +import tc.oc.api.docs.Whisper; +import tc.oc.api.docs.virtual.WhisperDoc; +import tc.oc.api.http.HttpOption; +import tc.oc.api.model.HttpModelService; +import tc.oc.api.whispers.WhisperService; + +@Singleton +class OCNWhisperService extends HttpModelService implements WhisperService { + + @Override + public ListenableFuture forReply(PlayerId user) { + final String uri = UriTemplate.fromTemplate("/{model}/reply/{user}") + .set("model", "whispers") + .set("user", user._id()) + .expand(); + return client().get(uri, Whisper.class, HttpOption.INFINITE_RETRY); + } +} diff --git a/API/ocn/src/main/resources/plugin.yml b/API/ocn/src/main/resources/plugin.yml new file mode 100644 index 0000000..a6d89d4 --- /dev/null +++ b/API/ocn/src/main/resources/plugin.yml @@ -0,0 +1,5 @@ +name: API-OCN +main: tc.oc.api.ocn.OCNApiManifest +version: ${project.version}-${git.commit.id.abbrev} +author: Overcast Network +depends: [API] diff --git a/API/pom.xml b/API/pom.xml new file mode 100644 index 0000000..05b29cb --- /dev/null +++ b/API/pom.xml @@ -0,0 +1,22 @@ + + 4.0.0 + + + tc.oc + ProjectAres + ../pom.xml + 1.11-SNAPSHOT + + + api-parent + pom + API Parent + + + api + minecraft + ocn + bukkit + bungee + + diff --git a/Commons/bukkit/pom.xml b/Commons/bukkit/pom.xml new file mode 100644 index 0000000..467baae --- /dev/null +++ b/Commons/bukkit/pom.xml @@ -0,0 +1,108 @@ + + + 4.0.0 + + + commons + tc.oc + ../pom.xml + 1.11-SNAPSHOT + + + commons-bukkit + + + + Commons + + tc.oc.commons.bukkit.CommonsBukkitManifest + + + + + + com.google.guava + guava + + + + + + tc.oc + commons-core + ${project.version} + + + + tc.oc + api-bukkit + ${project.version} + + + + com.github.rmsy.Channels + Channels + 1.9-SNAPSHOT + + + org.bukkit + bukkit + + + + + + me.anxuiz + bukkit-settings + + + + + + + junit + junit + 4.10 + test + + + + + + + org.apache.maven.plugins + maven-shade-plugin + 2.1 + + + package + + shade + + + + + tc.oc:commons-core + + + + + + + + pl.project13.maven + git-commit-id-plugin + 2.1.0 + + + + revision + + + + + + + diff --git a/Commons/bukkit/src/main/java/tc/oc/bukkit/analytics/BukkitPlayerReporter.java b/Commons/bukkit/src/main/java/tc/oc/bukkit/analytics/BukkitPlayerReporter.java new file mode 100644 index 0000000..57941fe --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/bukkit/analytics/BukkitPlayerReporter.java @@ -0,0 +1,21 @@ +package tc.oc.bukkit.analytics; + +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import tc.oc.minecraft.analytics.PlayerReporter; + +public class BukkitPlayerReporter extends PlayerReporter implements Listener { + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void join(PlayerJoinEvent event) { + join(); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void leave(PlayerQuitEvent event) { + leave(); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/bukkit/analytics/LatencyReporter.java b/Commons/bukkit/src/main/java/tc/oc/bukkit/analytics/LatencyReporter.java new file mode 100644 index 0000000..e45d450 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/bukkit/analytics/LatencyReporter.java @@ -0,0 +1,41 @@ +package tc.oc.bukkit.analytics; + +import java.time.Duration; +import javax.inject.Inject; + +import tc.oc.analytics.Gauge; +import tc.oc.analytics.MetricFactory; +import tc.oc.api.bukkit.users.OnlinePlayers; +import tc.oc.commons.bukkit.util.NMSHacks; +import tc.oc.commons.core.plugin.PluginFacet; +import tc.oc.commons.core.util.Numbers; +import tc.oc.minecraft.analytics.AnalyticsFacet; +import tc.oc.minecraft.api.scheduler.Tickable; + +public class LatencyReporter extends AnalyticsFacet implements PluginFacet, Tickable { + + private final OnlinePlayers onlinePlayers; + private final Gauge latency; + + @Inject LatencyReporter(OnlinePlayers onlinePlayers, MetricFactory metrics) { + this.onlinePlayers = onlinePlayers; + this.latency = metrics.gauge("bukkit.latency"); + } + + @Override + public Duration tickPeriod() { + return Duration.ofSeconds(10); + } + + @Override + public void tick() { + onlinePlayers.all() + .stream() + .mapToInt(NMSHacks::playerLatencyMillis) + .average() + .ifPresent(average -> latency.measure(Numbers.clamp( + average, + 0, 2000 // Filter out insane values so our graphs don't get wrecked + ))); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/bukkit/analytics/TickReporter.java b/Commons/bukkit/src/main/java/tc/oc/bukkit/analytics/TickReporter.java new file mode 100644 index 0000000..d1dbbe7 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/bukkit/analytics/TickReporter.java @@ -0,0 +1,47 @@ +package tc.oc.bukkit.analytics; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; +import javax.inject.Inject; + +import tc.oc.analytics.Count; +import tc.oc.analytics.Gauge; +import tc.oc.analytics.MetricFactory; +import tc.oc.commons.bukkit.util.NMSHacks; +import tc.oc.commons.core.plugin.PluginFacet; +import tc.oc.minecraft.analytics.AnalyticsFacet; +import tc.oc.minecraft.api.scheduler.Tickable; + +public class TickReporter extends AnalyticsFacet implements PluginFacet, Tickable { + + private static final Duration INITIAL_DELAY = Duration.ofSeconds(10); + + private final Count tickCount; + private final Gauge tickDuration; + private final Gauge tickInterval; + + private long lastTickNanos = Long.MIN_VALUE; + + @Inject TickReporter(MetricFactory metrics) { + tickCount = metrics.count("bukkit.ticks"); + tickDuration = metrics.gauge("bukkit.tick_duration"); + tickInterval = metrics.gauge("bukkit.tick_interval"); + } + + @Override + public Duration initialDelay() { + return INITIAL_DELAY; + } + + @Override + public void tick() { + tickDuration.measure((double) NMSHacks.lastTickDurationNanos() / TimeUnit.MILLISECONDS.toNanos(1)); + tickCount.increment(); + + final long now = System.nanoTime(); + if(lastTickNanos > Long.MIN_VALUE) { + tickInterval.measure((double) (now - lastTickNanos) / TimeUnit.MILLISECONDS.toNanos(1)); + } + lastTickNanos = now; + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/CommonsBukkitManifest.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/CommonsBukkitManifest.java new file mode 100644 index 0000000..52db0d5 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/CommonsBukkitManifest.java @@ -0,0 +1,227 @@ +package tc.oc.commons.bukkit; + +import javax.inject.Inject; + +import net.md_5.bungee.api.chat.TextComponent; +import net.md_5.bungee.api.chat.TranslatableComponent; +import org.bukkit.command.ConsoleCommandSender; +import org.bukkit.permissions.PermissionAttachment; +import org.bukkit.plugin.Plugin; +import tc.oc.api.util.Permissions; +import tc.oc.bukkit.analytics.BukkitPlayerReporter; +import tc.oc.bukkit.analytics.LatencyReporter; +import tc.oc.bukkit.analytics.TickReporter; +import tc.oc.commons.bukkit.broadcast.BroadcastManifest; +import tc.oc.commons.bukkit.channels.AdminChatManifest; +import tc.oc.commons.bukkit.chat.ComponentRenderContext; +import tc.oc.commons.bukkit.chat.ComponentRendererRegistry; +import tc.oc.commons.bukkit.chat.ComponentRenderers; +import tc.oc.commons.bukkit.chat.FullNameRenderer; +import tc.oc.commons.bukkit.chat.NameRenderer; +import tc.oc.commons.bukkit.chat.PlayerComponent; +import tc.oc.commons.bukkit.chat.PlayerComponentRenderer; +import tc.oc.commons.bukkit.chat.TextComponentRenderer; +import tc.oc.commons.bukkit.chat.TranslatableComponentRenderer; +import tc.oc.commons.bukkit.chat.UserTextComponent; +import tc.oc.commons.bukkit.chat.UserTextComponentRenderer; +import tc.oc.commons.bukkit.commands.PermissionCommands; +import tc.oc.commons.bukkit.commands.ServerCommands; +import tc.oc.commons.bukkit.commands.ServerVisibilityCommands; +import tc.oc.commons.bukkit.commands.SkinCommands; +import tc.oc.commons.bukkit.commands.TraceCommands; +import tc.oc.commons.bukkit.commands.UserCommands; +import tc.oc.commons.bukkit.commands.UserFinder; +import tc.oc.commons.bukkit.debug.LeakListener; +import tc.oc.commons.bukkit.event.targeted.TargetedEventManifest; +import tc.oc.commons.bukkit.format.ServerFormatter; +import tc.oc.commons.bukkit.freeze.PlayerFreezer; +import tc.oc.commons.bukkit.inject.BukkitPluginManifest; +import tc.oc.commons.bukkit.inject.ComponentRendererModule; +import tc.oc.commons.bukkit.listeners.AppealAlertListener; +import tc.oc.commons.bukkit.listeners.ButtonManager; +import tc.oc.commons.bukkit.listeners.InactivePlayerListener; +import tc.oc.commons.bukkit.listeners.LocaleListener; +import tc.oc.commons.bukkit.listeners.LoginListener; +import tc.oc.commons.bukkit.listeners.PermissionGroupListener; +import tc.oc.commons.bukkit.listeners.PlayerMovementListener; +import tc.oc.commons.bukkit.listeners.WindowManager; +import tc.oc.commons.bukkit.localization.BukkitTranslator; +import tc.oc.commons.bukkit.localization.BukkitTranslatorImpl; +import tc.oc.commons.bukkit.localization.LocalizationManifest; +import tc.oc.commons.bukkit.localization.Translations; +import tc.oc.commons.bukkit.localization.Translator; +import tc.oc.commons.bukkit.logging.MapdevLogger; +import tc.oc.commons.bukkit.nick.IdentityProvider; +import tc.oc.commons.bukkit.nick.IdentityProviderImpl; +import tc.oc.commons.bukkit.nick.NicknameCommands; +import tc.oc.commons.bukkit.nick.PlayerAppearanceChanger; +import tc.oc.commons.bukkit.nick.PlayerAppearanceListener; +import tc.oc.commons.bukkit.nick.PlayerOrder; +import tc.oc.commons.bukkit.nick.PlayerOrderCache; +import tc.oc.commons.bukkit.punishment.PunishmentManifest; +import tc.oc.commons.bukkit.raindrops.RaindropManifest; +import tc.oc.commons.bukkit.report.ReportAnnouncer; +import tc.oc.commons.bukkit.report.ReportCommands; +import tc.oc.commons.bukkit.respack.ResourcePackCommands; +import tc.oc.commons.bukkit.respack.ResourcePackListener; +import tc.oc.commons.bukkit.respack.ResourcePackManager; +import tc.oc.commons.bukkit.restart.RestartCommands; +import tc.oc.commons.bukkit.sessions.SessionListener; +import tc.oc.commons.bukkit.settings.SettingManifest; +import tc.oc.commons.bukkit.suspend.SuspendListener; +import tc.oc.commons.bukkit.tablist.PlayerTabEntry; +import tc.oc.commons.bukkit.tablist.TabRender; +import tc.oc.commons.bukkit.teleport.NavigatorManifest; +import tc.oc.commons.bukkit.teleport.PlayerServerChanger; +import tc.oc.commons.bukkit.teleport.TeleportCommands; +import tc.oc.commons.bukkit.teleport.TeleportListener; +import tc.oc.commons.bukkit.teleport.Teleporter; +import tc.oc.commons.bukkit.ticket.TicketBooth; +import tc.oc.commons.bukkit.ticket.TicketCommands; +import tc.oc.commons.bukkit.ticket.TicketDisplay; +import tc.oc.commons.bukkit.ticket.TicketListener; +import tc.oc.commons.bukkit.trophies.TrophyCase; +import tc.oc.commons.bukkit.trophies.TrophyCommands; +import tc.oc.commons.bukkit.users.JoinMessageManifest; +import tc.oc.commons.bukkit.util.PlayerStates; +import tc.oc.commons.bukkit.util.PlayerStatesImpl; +import tc.oc.commons.bukkit.whisper.WhisperManifest; +import tc.oc.commons.bukkit.whitelist.Whitelist; +import tc.oc.commons.bukkit.whitelist.WhitelistCommands; +import tc.oc.commons.core.CommonsCoreManifest; +import tc.oc.commons.core.chat.Component; +import tc.oc.commons.core.inject.HybridManifest; +import tc.oc.commons.core.plugin.PluginFacetBinder; +import tc.oc.minecraft.api.event.Enableable; +import tc.oc.minecraft.api.event.ListenerBinder; + +public final class CommonsBukkitManifest extends HybridManifest { + @Override + protected void configure() { + install(new CommonsCoreManifest()); + install(new BukkitPluginManifest()); + install(new TargetedEventManifest()); + + install(new SettingManifest()); + install(new WhisperManifest()); + install(new JoinMessageManifest()); + install(new AdminChatManifest()); + install(new BroadcastManifest()); + install(new LocalizationManifest()); + install(new NavigatorManifest()); + install(new RaindropManifest()); + install(new PunishmentManifest()); + + // These are already bound as facets, so they only need to be exposed + expose(PlayerFreezer.class); + expose(PlayerServerChanger.class); + expose(LeakListener.class); + expose(TicketDisplay.class); + expose(TicketListener.class); + + bindAndExpose(PlayerAppearanceChanger.class); + bindAndExpose(UserFinder.class); + bindAndExpose(Teleporter.class); + bindAndExpose(TicketBooth.class); + bindAndExpose(MapdevLogger.class); + bindAndExpose(TrophyCase.class); + + bindAndExpose(Translator.class).to(Translations.class); + bindAndExpose(BukkitTranslator.class).to(BukkitTranslatorImpl.class); + bindAndExpose(PlayerOrder.Factory.class).to(PlayerOrderCache.class); + bindAndExpose(IdentityProvider.class).to(IdentityProviderImpl.class); + bindAndExpose(ResourcePackManager.class).to(ResourcePackListener.class); + bindAndExpose(NameRenderer.class).to(FullNameRenderer.class); + bindAndExpose(ComponentRenderContext.class).to(ComponentRendererRegistry.class); + bindAndExpose(PlayerStates.class).to(PlayerStatesImpl.class); + + installAndExpose(new ComponentRendererModule() { + @Override + protected void configure() { + bindComponent(TextComponent.class).to(TextComponentRenderer.class); + bindComponent(Component.class).to(TextComponentRenderer.class); + bindComponent(TranslatableComponent.class).to(TranslatableComponentRenderer.class); + bindComponent(PlayerComponent.class).to(PlayerComponentRenderer.class); + bindComponent(UserTextComponent.class).to(UserTextComponentRenderer.class); + } + }); + + final PluginFacetBinder facets = new PluginFacetBinder(binder()); + facets.register(TicketCommands.class); + facets.register(PlayerMovementListener.class); + facets.register(ButtonManager.class); + facets.register(IdentityProviderImpl.class); + facets.register(InactivePlayerListener.class); + facets.register(LeakListener.class); + facets.register(LocaleListener.class); + facets.register(LoginListener.class); + facets.register(NicknameCommands.class); + facets.register(PermissionCommands.class); + facets.register(PermissionCommands.Parent.class); + facets.register(PermissionGroupListener.class); + facets.register(PlayerAppearanceListener.class); + facets.register(PlayerFreezer.class); + facets.register(PlayerOrderCache.class); + facets.register(PlayerServerChanger.class); + facets.register(ReportAnnouncer.class); + facets.register(ReportCommands.class); + facets.register(ResourcePackCommands.class); + facets.register(ResourcePackCommands.Parent.class); + facets.register(ResourcePackListener.class); + facets.register(RestartCommands.class); + facets.register(ServerCommands.class); + facets.register(ServerVisibilityCommands.class); + facets.register(SessionListener.class); + facets.register(SkinCommands.class); + facets.register(SkinCommands.Parent.class); + facets.register(TeleportCommands.class); + facets.register(TeleportListener.class); + facets.register(TicketDisplay.class); + facets.register(TicketListener.class); + facets.register(TrophyCommands.class); + facets.register(TrophyCommands.Parent.class); + facets.register(TraceCommands.class); + facets.register(TraceCommands.Parent.class); + facets.register(UserCommands.class); + facets.register(Whitelist.class); + facets.register(WhitelistCommands.class); + facets.register(WhitelistCommands.Parent.class); + facets.register(WindowManager.class); + facets.register(AppealAlertListener.class); + facets.register(SuspendListener.class); + + // DataDog + facets.register(TickReporter.class); + facets.register(BukkitPlayerReporter.class); + facets.register(LatencyReporter.class); + + // Hall of shame + requestStaticInjection(ComponentRenderers.class); + requestStaticInjection(PlayerTabEntry.class); + requestStaticInjection(TabRender.class); + requestStaticInjection(ServerFormatter.class); + + new ListenerBinder(binder()) + .bindListener().to(RegisterConsolePermissions.class); + } + + static class RegisterConsolePermissions implements Enableable { + + @Inject Plugin plugin; + @Inject ConsoleCommandSender console; + + PermissionAttachment attachment; + + @Override + public void enable() { + attachment = console.addAttachment(plugin, Permissions.CONSOLE, true); + } + + @Override + public void disable() { + if(attachment != null) { + attachment.remove(); + } + } + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/broadcast/BroadcastFormatter.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/broadcast/BroadcastFormatter.java new file mode 100644 index 0000000..a9f9e29 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/broadcast/BroadcastFormatter.java @@ -0,0 +1,30 @@ +package tc.oc.commons.bukkit.broadcast; + +import java.util.Map; + +import com.google.common.collect.ImmutableMap; +import net.md_5.bungee.api.ChatColor; +import net.md_5.bungee.api.chat.BaseComponent; +import net.md_5.bungee.api.chat.TranslatableComponent; +import tc.oc.commons.bukkit.broadcast.model.BroadcastPrefix; +import tc.oc.commons.core.chat.Component; + +public class BroadcastFormatter { + + private static final Map COLORS = ImmutableMap.builder() + .put(BroadcastPrefix.TIP, ChatColor.BLUE) + .put(BroadcastPrefix.NEWS, ChatColor.YELLOW) + .put(BroadcastPrefix.ALERT, ChatColor.RED) + .put(BroadcastPrefix.INFO, ChatColor.LIGHT_PURPLE) + .put(BroadcastPrefix.FACT, ChatColor.GOLD) + .put(BroadcastPrefix.CHAT, ChatColor.GREEN) + .build(); + + public BaseComponent broadcast(BroadcastPrefix prefix, BaseComponent content) { + return new Component(ChatColor.GRAY, ChatColor.BOLD) + .extra("[") + .extra(new Component(new TranslatableComponent("prefixed." + prefix.name().toLowerCase()), COLORS.get(prefix))) + .extra("] ") + .extra(new Component(content, ChatColor.AQUA).bold(false)); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/broadcast/BroadcastManifest.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/broadcast/BroadcastManifest.java new file mode 100644 index 0000000..ce9e77c --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/broadcast/BroadcastManifest.java @@ -0,0 +1,35 @@ +package tc.oc.commons.bukkit.broadcast; + +import java.util.List; + +import com.google.inject.TypeLiteral; +import tc.oc.commons.bukkit.broadcast.model.BroadcastPrefix; +import tc.oc.commons.bukkit.broadcast.model.BroadcastSchedule; +import tc.oc.commons.bukkit.settings.SettingBinder; +import tc.oc.commons.core.inject.HybridManifest; +import tc.oc.commons.core.plugin.PluginFacetBinder; +import tc.oc.commons.core.reflect.TypeLiterals; +import tc.oc.parse.DocumentWatcher; +import tc.oc.parse.EnumParserManifest; +import tc.oc.parse.ParserTypeLiterals; + +public class BroadcastManifest extends HybridManifest implements TypeLiterals, ParserTypeLiterals { + @Override + protected void configure() { + installFactory(new TypeLiteral>>(){}); + + bind(BroadcastFormatter.class); + + install(new EnumParserManifest<>(BroadcastPrefix.class)); + bind(DocumentParser(List(BroadcastSchedule.class))).to(BroadcastParser.class); + + new PluginFacetBinder(binder()).register(BroadcastScheduler.class); + + bind(BroadcastSettings.class); + final SettingBinder settings = new SettingBinder(publicBinder()); + settings.addBinding().toInstance(BroadcastSettings.TIPS); + settings.addBinding().toInstance(BroadcastSettings.NEWS); + settings.addBinding().toInstance(BroadcastSettings.FACTS); + settings.addBinding().toInstance(BroadcastSettings.RANDOM); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/broadcast/BroadcastParser.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/broadcast/BroadcastParser.java new file mode 100644 index 0000000..c601129 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/broadcast/BroadcastParser.java @@ -0,0 +1,59 @@ +package tc.oc.commons.bukkit.broadcast; + +import java.nio.file.Path; +import java.util.List; +import javax.inject.Inject; + +import java.time.Duration; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import tc.oc.commons.bukkit.broadcast.model.BroadcastPrefix; +import tc.oc.commons.bukkit.broadcast.model.BroadcastSchedule; +import tc.oc.commons.bukkit.broadcast.model.BroadcastSet; +import tc.oc.commons.core.stream.Collectors; +import tc.oc.minecraft.server.ServerFilter; +import tc.oc.parse.ParseException; +import tc.oc.parse.validate.NonZeroDuration; +import tc.oc.parse.validate.NormalizedPath; +import tc.oc.parse.xml.DocumentParser; +import tc.oc.parse.xml.ElementParser; +import tc.oc.parse.xml.NodeParser; +import tc.oc.parse.xml.ValidatingNodeParser; +import tc.oc.parse.xml.XML; + +public class BroadcastParser implements DocumentParser> { + + private final NodeParser durationParser; + private final NodeParser pathParser; + private final NodeParser prefixParser; + private final ElementParser serverFilterParser; + + @Inject BroadcastParser(NodeParser durationParser, NodeParser pathParser, NodeParser prefixParser, ElementParser serverFilterParser) { + this.durationParser = new ValidatingNodeParser<>(durationParser, new NonZeroDuration()); + this.pathParser = new ValidatingNodeParser<>(pathParser, new NormalizedPath()); + this.prefixParser = prefixParser; + this.serverFilterParser = serverFilterParser; + } + + @Override + public List parse(Document document) throws ParseException { + return XML.childrenNamed(document.getDocumentElement(), "broadcasts") + .flatMap(el -> XML.childrenNamed(el, "schedule")) + .map(this::parseSchedule) + .collect(Collectors.toImmutableList()); + } + + public BroadcastSchedule parseSchedule(Element el) throws ParseException { + return new BroadcastSchedule( + durationParser.parse(XML.requireAttr(el, "interval")), + serverFilterParser.parse(el), XML.childrenNamed(el, "messages").map(this::parseMessages) + ); + } + + public BroadcastSet parseMessages(Element el) { + return new BroadcastSet( + pathParser.parse(XML.requireAttr(el, "path")), + prefixParser.parse(XML.requireAttr(el, "prefix")) + ); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/broadcast/BroadcastScheduler.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/broadcast/BroadcastScheduler.java new file mode 100644 index 0000000..bf8fb70 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/broadcast/BroadcastScheduler.java @@ -0,0 +1,156 @@ +package tc.oc.commons.bukkit.broadcast; + +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.logging.Logger; +import java.util.stream.Stream; +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Lists; +import net.md_5.bungee.api.chat.BaseComponent; +import org.bukkit.command.ConsoleCommandSender; +import tc.oc.api.bukkit.users.OnlinePlayers; +import tc.oc.api.docs.Server; +import tc.oc.commons.bukkit.broadcast.model.BroadcastSchedule; +import tc.oc.commons.bukkit.broadcast.model.BroadcastSet; +import tc.oc.commons.bukkit.chat.ComponentRenderContext; +import tc.oc.commons.bukkit.localization.LocalizedMessageMap; +import tc.oc.commons.core.logging.Loggers; +import tc.oc.commons.core.plugin.PluginFacet; +import tc.oc.commons.core.scheduler.Scheduler; +import tc.oc.commons.core.scheduler.Task; +import tc.oc.commons.core.stream.Collectors; +import tc.oc.commons.core.util.Pair; +import tc.oc.parse.DocumentWatcher; + +/** + * Manages all broadcast messages + */ +@Singleton +public class BroadcastScheduler implements PluginFacet { + + // Configuration file, relative to config root + private static final Path CONFIG_FILE = Paths.get("broadcasts.xml"); + + private static final Path SOURCES_PATH = Paths.get("localized/broadcasts"); + + // Base path for (untranslated) message sources, relative to config root + private static final Path TRANSLATIONS_PATH = Paths.get("broadcasts"); + + private final Logger logger; + private final Path configPath; + private final Path configFile; + private final Scheduler scheduler; + private final ConsoleCommandSender console; + private final OnlinePlayers onlinePlayers; + private final LocalizedMessageMap.Factory messageMapFactory; + private final DocumentWatcher.Factory> documentWatcherFactory; + private final Server localServer; + private final BroadcastFormatter formatter; + private final ComponentRenderContext renderer; + private final BroadcastSettings settings; + + private final Random random = new Random(); + + private DocumentWatcher> scheduleWatcher; + private List tasks = ImmutableList.of(); + + @Inject BroadcastScheduler(Loggers loggers, + @Named("configuration") Path configPath, + Scheduler scheduler, + ConsoleCommandSender console, + OnlinePlayers onlinePlayers, + LocalizedMessageMap.Factory messageMapFactory, + DocumentWatcher.Factory> documentWatcherFactory, + Server localServer, + BroadcastFormatter formatter, + ComponentRenderContext renderer, + BroadcastSettings settings) { + + this.logger = loggers.get(getClass()); + this.configPath = configPath; + this.configFile = configPath.resolve(CONFIG_FILE); + this.scheduler = scheduler; + this.console = console; + this.onlinePlayers = onlinePlayers; + this.messageMapFactory = messageMapFactory; + this.documentWatcherFactory = documentWatcherFactory; + this.localServer = localServer; + this.formatter = formatter; + this.renderer = renderer; + this.settings = settings; + } + + @Override + public void enable() { + scheduleWatcher = documentWatcherFactory.create(configFile, schedule -> { + tasks.forEach(ScheduledTask::cancel); + tasks = schedule.map(doc -> ImmutableList.copyOf(Lists.transform(doc, ScheduledTask::new))) + .orElse(ImmutableList.of()); + }); + } + + @Override + public void disable() { + tasks.forEach(ScheduledTask::cancel); + + if(scheduleWatcher != null) { + scheduleWatcher.cancel(); + scheduleWatcher = null; + } + } + + /** + * Handles a single {@link BroadcastSchedule} + */ + private class ScheduledTask { + + final BroadcastSchedule schedule; + final Map messages; + final Task task; + + ScheduledTask(BroadcastSchedule schedule) { + logger.fine(() -> "Starting broadcast schedule " + schedule); + + this.schedule = schedule; + this.messages = schedule.messages().stream().collect(Collectors.mappingTo( + set -> messageMapFactory.create(configPath.resolve(SOURCES_PATH).resolve(set.path()), + TRANSLATIONS_PATH.resolve(set.path()))) + ); + this.task = scheduler.createRepeatingTask(schedule.interval(), this::dispatch); + } + + void dispatch() { + if(!schedule.serverFilter().test(localServer)) return; + + final List> choices = messages.entrySet() + .stream() + .flatMap(entry -> entry.getValue() + .keySet() + .stream() + .map(key -> Pair.of(entry.getKey(), key))) + .collect(Collectors.toImmutableList()); + if(choices.isEmpty()) return; + + final Pair choice = choices.get(random.nextInt(choices.size())); + final BaseComponent message = formatter.broadcast(choice.first.prefix(), + messages.get(choice.first).get(choice.second)); + Stream.concat(Stream.of(console), onlinePlayers.all().stream()).forEach(viewer -> { + if(settings.isVisible(choice.first.prefix(), viewer)) { + renderer.send(message, viewer); + } + }); + } + + void cancel() { + task.cancel(); + messages.values().forEach(LocalizedMessageMap::disable); + } + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/broadcast/BroadcastSettings.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/broadcast/BroadcastSettings.java new file mode 100644 index 0000000..73b5115 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/broadcast/BroadcastSettings.java @@ -0,0 +1,79 @@ +package tc.oc.commons.bukkit.broadcast; + +import javax.inject.Inject; + +import me.anxuiz.settings.Setting; +import me.anxuiz.settings.SettingBuilder; +import me.anxuiz.settings.types.BooleanType; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import tc.oc.commons.bukkit.broadcast.model.BroadcastPrefix; +import tc.oc.commons.bukkit.settings.SettingManagerProvider; + +public class BroadcastSettings { + + public static final Setting TIPS = new SettingBuilder() + .name("Tips") + .summary("Show tips in chat") + .type(new BooleanType()) + .defaultValue(true) + .get(); + + public static final Setting NEWS = new SettingBuilder() + .name("News") + .summary("Show news and alerts in chat") + .type(new BooleanType()) + .defaultValue(true) + .get(); + + public static final Setting FACTS = new SettingBuilder() + .name("Facts") + .summary("Show facts and knowledge in chat") + .type(new BooleanType()) + .defaultValue(true) + .get(); + + public static final Setting RANDOM = new SettingBuilder() + .name("Random") + .summary("Show random wisdom in chat") + .type(new BooleanType()) + .defaultValue(true) + .get(); + + private final SettingManagerProvider settings; + + @Inject BroadcastSettings(SettingManagerProvider settings) { + this.settings = settings; + } + + public boolean isVisible(BroadcastPrefix prefix, CommandSender viewer) { + if(!(viewer instanceof Player)) return true; + + final Player player = (Player) viewer; + final Setting setting; + switch(prefix) { + case TIP: + setting = TIPS; + break; + + case NEWS: + case ALERT: + setting = NEWS; + break; + + case INFO: + case FACT: + setting = FACTS; + break; + + case CHAT: + setting = RANDOM; + break; + + default: + return true; + } + + return (boolean) settings.getManager(player).getValue(setting); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/broadcast/model/BroadcastPrefix.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/broadcast/model/BroadcastPrefix.java new file mode 100644 index 0000000..c7b9421 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/broadcast/model/BroadcastPrefix.java @@ -0,0 +1,5 @@ +package tc.oc.commons.bukkit.broadcast.model; + +public enum BroadcastPrefix { + TIP, NEWS, ALERT, INFO, FACT, CHAT; +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/broadcast/model/BroadcastSchedule.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/broadcast/model/BroadcastSchedule.java new file mode 100644 index 0000000..744f010 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/broadcast/model/BroadcastSchedule.java @@ -0,0 +1,46 @@ +package tc.oc.commons.bukkit.broadcast.model; + +import java.util.List; +import java.util.stream.Stream; + +import com.google.common.collect.ImmutableList; +import java.time.Duration; +import tc.oc.commons.core.inspect.Inspectable; +import tc.oc.commons.core.stream.Collectors; +import tc.oc.minecraft.server.ServerFilter; + +/** + * A periodic broadcast selected randomly from a set of localized messages + */ +public class BroadcastSchedule extends Inspectable.Impl { + + @Inspect private final Duration interval; + @Inspect private final ImmutableList messages; + @Inspect private final ServerFilter serverFilter; + + public BroadcastSchedule(Duration interval, ServerFilter serverFilter, Stream messages) { + this.interval = interval; + this.serverFilter = serverFilter; + this.messages = messages.collect(Collectors.toImmutableList()); + } + + /** + * Time between broadcasts + */ + public Duration interval() { + return interval; + } + + /** + * Relative path of the localized message list. + * + * This path is be relative to the localized root, and must NOT have an extension. + */ + public List messages() { + return messages; + } + + public ServerFilter serverFilter() { + return serverFilter; + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/broadcast/model/BroadcastSet.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/broadcast/model/BroadcastSet.java new file mode 100644 index 0000000..d8ca40c --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/broadcast/model/BroadcastSet.java @@ -0,0 +1,24 @@ +package tc.oc.commons.bukkit.broadcast.model; + +import java.nio.file.Path; + +import tc.oc.commons.core.inspect.Inspectable; + +public class BroadcastSet extends Inspectable.Impl { + + @Inspect private final Path path; + @Inspect private final BroadcastPrefix prefix; + + public BroadcastSet(Path path, BroadcastPrefix prefix) { + this.path = path; + this.prefix = prefix; + } + + public Path path() { + return path; + } + + public BroadcastPrefix prefix() { + return prefix; + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/channels/AdminChannel.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/channels/AdminChannel.java new file mode 100644 index 0000000..fd6def3 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/channels/AdminChannel.java @@ -0,0 +1,129 @@ +package tc.oc.commons.bukkit.channels; + +import java.util.stream.Stream; +import javax.inject.Inject; +import javax.inject.Singleton; + +import com.github.rmsy.channels.Channel; +import com.github.rmsy.channels.ChannelsPlugin; +import com.github.rmsy.channels.PlayerManager; +import com.github.rmsy.channels.event.ChannelMessageEvent; +import com.github.rmsy.channels.impl.SimpleChannel; +import com.sk89q.minecraft.util.commands.Command; +import com.sk89q.minecraft.util.commands.CommandContext; +import com.sk89q.minecraft.util.commands.CommandException; +import com.sk89q.minecraft.util.commands.CommandPermissions; +import com.sk89q.minecraft.util.commands.CommandPermissionsException; +import com.sk89q.minecraft.util.commands.CommandUsageException; +import com.sk89q.minecraft.util.commands.Console; +import me.anxuiz.settings.Setting; +import me.anxuiz.settings.SettingBuilder; +import me.anxuiz.settings.types.BooleanType; +import org.bukkit.ChatColor; +import org.bukkit.command.CommandSender; +import org.bukkit.command.ConsoleCommandSender; +import org.bukkit.entity.Player; +import org.bukkit.permissions.Permission; +import org.bukkit.permissions.PermissionDefault; +import tc.oc.api.bukkit.users.OnlinePlayers; +import tc.oc.commons.bukkit.settings.SettingManagerProvider; +import tc.oc.commons.core.commands.Commands; + +@Singleton +public class AdminChannel extends SimpleChannel implements Commands { + + static final Setting SETTING = new SettingBuilder() + .name("AdminChat").alias("ac") + .summary("Show confidential staff info") + .type(new BooleanType()) + .defaultValue(true).get(); + + public static final String PERM_NODE = "chat.admin"; + public static final String PERM_SEND = PERM_NODE + ".send"; + public static final String PERM_RECEIVE = PERM_NODE + ".receive"; + + public static final String PREFIX = ChatColor.WHITE + "[" + ChatColor.GOLD + "A" + ChatColor.WHITE + "]"; + public static final String BROADCAST_FORMAT = PREFIX + " {2}"; + public static final String FORMAT = PREFIX + " {1}" + ChatColor.WHITE + ": {2}"; + + private final ConsoleCommandSender console; + private final OnlinePlayers players; + private final SettingManagerProvider settings; + + @Inject AdminChannel(ConsoleCommandSender console, OnlinePlayers players, SettingManagerProvider settings) { + super(FORMAT, BROADCAST_FORMAT, new Permission(PERM_RECEIVE, PermissionDefault.OP)); + this.players = players; + this.settings = settings; + this.console = console; + } + + @Command(aliases = "a", + desc = "Sends a message to the staff channel (or sets the staff channel to your default channel).", + max = -1, + min = 0, + anyFlags = true, + usage = "[message...]") + @Console + @CommandPermissions({PERM_SEND, PERM_RECEIVE}) + public void onAdminChatCommand(final CommandContext arguments, final CommandSender sender) throws CommandException { + if(arguments.argsLength() == 0) { + if (sender.hasPermission(PERM_RECEIVE)) { + if (sender instanceof Player) { + Player player = (Player) sender; + PlayerManager playerManager = ChannelsPlugin.get().getPlayerManager(); + Channel oldChannel = playerManager.getMembershipChannel(player); + playerManager.setMembershipChannel(player, this); + if (!oldChannel.equals(this)) { + sender.sendMessage(org.bukkit.ChatColor.YELLOW + "Changed default channel to administrator chat"); + } else { + throw new CommandException("Administrator chat is already your default channel"); + } + } else { + throw new CommandUsageException("You must provide a message.", "/a "); + } + } else { + throw new CommandPermissionsException(); + } + } else if (sender.hasPermission(PERM_SEND)) { + Player sendingPlayer = null; + if (sender instanceof Player) { + sendingPlayer = (Player) sender; + } + this.sendMessage(arguments.getJoinedStrings(0), sendingPlayer); + if (!sender.hasPermission(PERM_RECEIVE)) { + sender.sendMessage(org.bukkit.ChatColor.YELLOW + "Message sent"); + } + } else { + throw new CommandPermissionsException(); + } + } + + @Override + public void sendMessageToViewer(Player sender, CommandSender viewer, String sanitizedMessage, ChannelMessageEvent event) { + if(viewer != null && !isEnabled(viewer)) { + return; + } + + super.sendMessageToViewer(sender, viewer, sanitizedMessage, event); + } + + public boolean isEnabled(Player viewer) { + return (boolean) settings.getManager(viewer) + .getValue(SETTING); + } + + public boolean isEnabled(CommandSender viewer) { + return !(viewer instanceof Player) || isEnabled((Player) viewer); + } + + public boolean isVisible(CommandSender viewer) { + return viewer.hasPermission(getListeningPermission()) && + isEnabled(viewer); + } + + public Stream viewers() { + return Stream.concat(Stream.of(console), + players.all().stream()) + .filter(this::isVisible); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/channels/AdminChatManifest.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/channels/AdminChatManifest.java new file mode 100644 index 0000000..1aa2569 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/channels/AdminChatManifest.java @@ -0,0 +1,18 @@ +package tc.oc.commons.bukkit.channels; + +import tc.oc.commons.bukkit.settings.SettingBinder; +import tc.oc.commons.core.inject.HybridManifest; +import tc.oc.commons.core.plugin.PluginFacetBinder; + +public class AdminChatManifest extends HybridManifest { + @Override + protected void configure() { + new SettingBinder(publicBinder()) + .addBinding().toInstance(AdminChannel.SETTING); + + new PluginFacetBinder(binder()) + .register(AdminChannel.class); + + expose(AdminChannel.class); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/CachingNameRenderer.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/CachingNameRenderer.java new file mode 100644 index 0000000..080e839 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/CachingNameRenderer.java @@ -0,0 +1,56 @@ +package tc.oc.commons.bukkit.chat; + +import javax.annotation.Nullable; +import javax.inject.Inject; +import javax.inject.Singleton; + +import com.google.common.collect.HashBasedTable; +import com.google.common.collect.Table; +import net.md_5.bungee.api.chat.BaseComponent; +import tc.oc.commons.bukkit.nick.Identity; + +/** + * Caches rendered names, both component and legacy. The cache is keyed on + * the {@link Identity} and {@link NameType} that the name is generated from. + */ +@Singleton +public class CachingNameRenderer implements NameRenderer { + + private final NameRenderer nameRenderer; + private final Table components = HashBasedTable.create(); + private final Table legacy = HashBasedTable.create(); + + @Inject public CachingNameRenderer(NameRenderer nameRenderer) { + this.nameRenderer = nameRenderer; + } + + @Override + public String getLegacyName(Identity identity, NameType type) { + String rendered = legacy.get(identity, type); + if(rendered == null) { + rendered = nameRenderer.getLegacyName(identity, type); + legacy.put(identity, type, rendered); + } + return rendered; + } + + @Override + public BaseComponent getComponentName(Identity identity, NameType type) { + BaseComponent rendered = components.get(identity, type); + if(rendered == null) { + rendered = nameRenderer.getComponentName(identity, type); + components.put(identity, type, rendered); + } + return rendered; + } + + public void invalidateCache(@Nullable Identity identity) { + if(identity == null) { + components.clear(); + legacy.clear(); + } else { + components.rowKeySet().remove(identity); + legacy.rowKeySet().remove(identity); + } + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/ComponentPaginator.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/ComponentPaginator.java new file mode 100644 index 0000000..799cacc --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/ComponentPaginator.java @@ -0,0 +1,10 @@ +package tc.oc.commons.bukkit.chat; + +import net.md_5.bungee.api.chat.BaseComponent; + +public abstract class ComponentPaginator extends Paginator { + @Override + protected BaseComponent entry(BaseComponent entry, int index) { + return entry; + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/FlairRenderer.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/FlairRenderer.java new file mode 100644 index 0000000..de5aa57 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/FlairRenderer.java @@ -0,0 +1,55 @@ +package tc.oc.commons.bukkit.chat; + +import java.util.Set; +import javax.inject.Inject; +import javax.inject.Singleton; + +import com.google.common.collect.ImmutableSet; +import net.md_5.bungee.api.chat.BaseComponent; +import tc.oc.api.bukkit.users.BukkitUserStore; +import tc.oc.api.docs.virtual.UserDoc; +import tc.oc.api.minecraft.MinecraftService; +import tc.oc.commons.bukkit.nick.Identity; +import tc.oc.commons.core.chat.Components; + +/** + * Renders a player's flair only + */ +@Singleton +public class FlairRenderer implements PartialNameRenderer { + + private final MinecraftService minecraftService; + private final BukkitUserStore userStore; + + @Inject protected FlairRenderer(MinecraftService minecraftService, BukkitUserStore userStore) { + this.minecraftService = minecraftService; + this.userStore = userStore; + } + + @Override + public String getLegacyName(Identity identity, NameType type) { + if(!(type.style.contains(NameFlag.FLAIR) && type.reveal)) return ""; + + final UserDoc.Identity user; + if(identity.getPlayerId() instanceof UserDoc.Identity) { + // Flair may already be stashed inside the Identity + user = (UserDoc.Identity) identity.getPlayerId(); + } else { + user = userStore.tryUser(identity.getPlayerId()); + } + if(user == null) return ""; + + final Set realms = ImmutableSet.copyOf(minecraftService.getLocalServer().realms()); + + return user.minecraft_flair() + .stream() + .filter(flair -> realms.contains(flair.realm)) + .map(flair -> flair.text) + .reduce("", String::concat); + } + + @Override + public BaseComponent getComponentName(Identity identity, NameType type) { + return Components.fromLegacyText(getLegacyName(identity, type)); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/FullNameRenderer.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/FullNameRenderer.java new file mode 100644 index 0000000..35e25f4 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/FullNameRenderer.java @@ -0,0 +1,29 @@ +package tc.oc.commons.bukkit.chat; + +import javax.inject.Inject; + +import net.md_5.bungee.api.chat.BaseComponent; +import tc.oc.commons.bukkit.nick.Identity; +import tc.oc.commons.bukkit.nick.UsernameRenderer; +import tc.oc.commons.core.chat.Component; + +public class FullNameRenderer implements NameRenderer { + + private final FlairRenderer flairRenderer; + private final UsernameRenderer usernameRenderer; + + @Inject public FullNameRenderer(FlairRenderer flairRenderer, UsernameRenderer usernameRenderer) { + this.flairRenderer = flairRenderer; + this.usernameRenderer = usernameRenderer; + } + + @Override + public String getLegacyName(Identity identity, NameType type) { + return flairRenderer.getLegacyName(identity, type) + usernameRenderer.getLegacyName(identity, type); + } + + @Override + public BaseComponent getComponentName(Identity identity, NameType type) { + return new Component(flairRenderer.getComponentName(identity, type), usernameRenderer.getComponentName(identity, type)); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/LinkComponent.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/LinkComponent.java new file mode 100644 index 0000000..f4949b5 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/LinkComponent.java @@ -0,0 +1,94 @@ +package tc.oc.commons.bukkit.chat; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Optional; + +import net.md_5.bungee.api.ChatColor; +import net.md_5.bungee.api.chat.BaseComponent; +import net.md_5.bungee.api.chat.ClickEvent; +import org.bukkit.command.CommandSender; +import tc.oc.commons.core.chat.Component; +import tc.oc.commons.core.chat.ImmutableComponent; + +public class LinkComponent extends ImmutableComponent implements RenderableComponent { + + private final Renderable uri; + private final Optional content; + private final boolean compact; + + public LinkComponent(Renderable uri, Optional content) { + this.uri = uri; + this.content = content; + this.compact = true; + } + + public LinkComponent(Renderable uri, boolean compact) { + this.uri = uri; + this.content = Optional.empty(); + this.compact = compact; + } + + public LinkComponent(Renderable uri) { + this(uri, true); + } + + public LinkComponent(URI uri, Optional content) { + this(Renderable.of(uri), content); + } + + public LinkComponent(URI uri, boolean compact) { + this(Renderable.of(uri), compact); + } + + public LinkComponent(URI uri) { + this(uri, true); + } + + public LinkComponent(String uri, Optional content) throws URISyntaxException { + this(new URI(uri), content); + } + + public LinkComponent(String uri, boolean compact) throws URISyntaxException { + this(new URI(uri), compact); + } + + public LinkComponent(String uri) throws URISyntaxException { + this(new URI(uri)); + } + + public LinkComponent(String scheme, String host, String path, Optional content) throws URISyntaxException { + this(new URI(scheme, host, path, null), content); + } + + public LinkComponent(String scheme, String host, String path, boolean compact) throws URISyntaxException { + this(new URI(scheme, host, path, null), compact); + } + + public LinkComponent(String scheme, String host, String path) throws URISyntaxException { + this(new URI(scheme, host, path, null)); + } + + @Override + public BaseComponent render(ComponentRenderContext context, CommandSender viewer) { + final URI uri = this.uri.render(context, viewer); + return new Component(context.render(content.orElseGet(() -> displayLink(uri)), viewer), + ChatColor.BLUE, ChatColor.UNDERLINE) + .clickEvent(ClickEvent.Action.OPEN_URL, uri.toString()); + } + + private BaseComponent displayLink(URI uri) { + String display = uri.getHost(); + + // Don't append the path if it's just "/" + // Use the raw path with illegal chars, which tends to look nicer. + if(!"/".equals(uri.getRawPath())) { + display = display + uri.getRawPath(); + } + + if(!compact) { + display = uri.getScheme() + "://" + display; + } + return new Component(display); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/Links.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/Links.java new file mode 100644 index 0000000..d9ccc9b --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/Links.java @@ -0,0 +1,76 @@ +package tc.oc.commons.bukkit.chat; + +import java.net.URI; +import java.net.URISyntaxException; + +import net.md_5.bungee.api.ChatColor; +import net.md_5.bungee.api.chat.BaseComponent; +import net.md_5.bungee.api.chat.TranslatableComponent; +import tc.oc.api.docs.PlayerId; +import tc.oc.commons.core.chat.Component; +import tc.oc.commons.core.util.ExceptionUtils; + +public class Links { + private Links() {} + + public static final String HOST = "localhost"; // TODO: configurable + + public static URI homeUri(String path) throws URISyntaxException { + return new URI("http", HOST, path, null); + } + + public static URI homeUriSafe(String path) { + return ExceptionUtils.propagate(() -> homeUri(path)); + } + + public static BaseComponent homeLink(String path, boolean compact) throws URISyntaxException { + return new LinkComponent(homeUri(path), compact); + } + + public static BaseComponent homeLinkSafe(String path) { + return homeLinkSafe(path, true); + } + + public static BaseComponent homeLinkSafe(String path, boolean compact) { + return ExceptionUtils.propagate(() -> homeLink(path, compact)); + } + + public static BaseComponent homeLink() { + return homeLinkSafe("/"); + } + + public static BaseComponent shopLink() { + return homeLinkSafe("/shop"); + } + + public static BaseComponent appealLink() { + return homeLinkSafe("/appeal"); + } + + public static BaseComponent rulesLink() { + return homeLinkSafe("/rules"); + } + + public static BaseComponent shopPlug(String perk, Object... with) { + return new Component(ChatColor.LIGHT_PURPLE) + .extra(new TranslatableComponent(perk, with)) + .extra(new Component(" ")) + .extra(shopLink()); + } + + public static URI profileUri(String username) { + return homeUriSafe("/" + username); + } + + public static URI profileUri(PlayerId playerId) { + return homeUriSafe(playerId.username()); + } + + public static BaseComponent profileLink(String username) { + return homeLinkSafe("/" + username); + } + + public static BaseComponent profileLink(PlayerId playerId) { + return profileLink(playerId.username()); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/NameFlag.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/NameFlag.java new file mode 100644 index 0000000..6d4c7f6 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/NameFlag.java @@ -0,0 +1,16 @@ +package tc.oc.commons.bukkit.chat; + +/** + * Individual traits that make up {@link NameStyle}s + */ +public enum NameFlag { + COLOR, // Color + FLAIR, // Show flair + SELF, // Bold if self + FRIEND, // Italic if friend + DISGUISE, // Strikethrough if disguised + NICKNAME, // Show nickname after real name + DEATH, // Grey out name if dead + TELEPORT, // Click name to teleport + MAPMAKER; // Show mapmaker flair +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/NameRenderer.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/NameRenderer.java new file mode 100644 index 0000000..f018be7 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/NameRenderer.java @@ -0,0 +1,7 @@ +package tc.oc.commons.bukkit.chat; + +/** + * A {@link PartialNameRenderer} that renders complete names. + */ +public interface NameRenderer extends PartialNameRenderer { +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/NameStyle.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/NameStyle.java new file mode 100644 index 0000000..706c29c --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/NameStyle.java @@ -0,0 +1,73 @@ +package tc.oc.commons.bukkit.chat; + +import java.util.Collections; +import java.util.EnumSet; +import java.util.Set; + +import com.google.common.collect.ForwardingSet; +import com.google.common.collect.Sets; + +/** + * The formatting properties for each different context in which names are displayed. + * Unlike {@link NameType}, this varies only by context, and is independent of the viewer. + */ +public class NameStyle extends ForwardingSet { + + // No formatting + public static final NameStyle PLAIN = new NameStyle(EnumSet.noneOf(NameFlag.class)); + + // Just color + public static final NameStyle COLOR = new NameStyle(EnumSet.of( + NameFlag.COLOR, + NameFlag.TELEPORT) + ); + + // Flair, color, various other formatting, and click/hover actions + public static final NameStyle FANCY = new NameStyle(EnumSet.of( + NameFlag.COLOR, + NameFlag.FLAIR, + NameFlag.SELF, + NameFlag.FRIEND, + NameFlag.DISGUISE, + NameFlag.TELEPORT, + NameFlag.MAPMAKER) + ); + + // Fancy plus in-game status i.e. grey when dead + public static final NameStyle GAME = new NameStyle( + Sets.union( + FANCY, + Collections.singleton(NameFlag.DEATH) + ) + ); + + // Fancy plus full nickname + public static final NameStyle VERBOSE = new NameStyle( + Sets.union( + FANCY, + Collections.singleton(NameFlag.NICKNAME) + ) + ); + + + // Fancy minus mapmaker flair (for display in map credits) + public static final NameStyle MAPMAKER = new NameStyle( + Sets.difference( + FANCY, + Collections.singleton(NameFlag.MAPMAKER) + ) + ); + + private final EnumSet flags; + + public NameStyle(EnumSet flags) { + this.flags = flags; + } + + public NameStyle(Iterable flags) { + this(Sets.newEnumSet(flags, NameFlag.class)); + } + + @Override + protected Set delegate() { return flags; } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/NameType.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/NameType.java new file mode 100644 index 0000000..eb2b41d --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/NameType.java @@ -0,0 +1,58 @@ +package tc.oc.commons.bukkit.chat; + +import org.bukkit.command.CommandSender; +import tc.oc.commons.bukkit.nick.Identity; + +import java.util.Objects; + +import static com.google.common.base.Preconditions.checkNotNull; + +/** + * These are the parameters that determine how a player's name is + * rendered for a specific viewer, in a specific context. + * This is used as a cache key, so if a name changes appearance + * without any of these parameters changing, the cache must be + * invalidated by calling {@link CachingNameRenderer#invalidateCache}. + */ +public class NameType { + + public final NameStyle style; + public final boolean online; // Player is online + public final boolean reveal; // Player's true identity is visible + public final boolean self; // Player is viewing their own name + public final boolean friend; // Player is a friend (of the viewer) + public final boolean dead; // Player is dead + + public NameType(NameStyle style, boolean online, boolean reveal, boolean self, boolean friend, boolean dead) { + this.style = checkNotNull(style); + this.online = online; + this.reveal = reveal; + this.self = self; + this.friend = friend; + this.dead = dead; + } + + public NameType(NameStyle style, Identity identity, CommandSender viewer) { + this(style, identity.isOnline(viewer), identity.isRevealed(viewer), identity.belongsTo(viewer), identity.isFriend(viewer), identity.isDead(viewer)); + } + + @Override + public boolean equals(Object o) { + if(this == o) + return true; + if(!(o instanceof NameType)) + return false; + NameType other = (NameType) o; + return Objects.equals(style, other.style) && + online == other.online && + reveal == other.reveal && + self == other.self && + friend == other.friend && + dead == other.dead; + } + + @Override + public int hashCode() { + return Objects.hash(style, online, reveal, self, friend, dead); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/Named.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/Named.java new file mode 100644 index 0000000..a04c398 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/Named.java @@ -0,0 +1,7 @@ +package tc.oc.commons.bukkit.chat; + +import net.md_5.bungee.api.chat.BaseComponent; + +public interface Named { + BaseComponent getStyledName(NameStyle style); +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/Paginator.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/Paginator.java new file mode 100644 index 0000000..051d879 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/Paginator.java @@ -0,0 +1,115 @@ +package tc.oc.commons.bukkit.chat; + +import java.util.Collection; +import java.util.Iterator; +import java.util.List; +import javax.annotation.Nullable; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Iterators; +import net.md_5.bungee.api.ChatColor; +import net.md_5.bungee.api.chat.BaseComponent; +import net.md_5.bungee.api.chat.TranslatableComponent; +import org.bukkit.command.CommandSender; +import tc.oc.commons.core.chat.Audience; +import tc.oc.commons.core.chat.Component; +import tc.oc.commons.core.util.IndexedFunction; +import tc.oc.commons.core.util.Numbers; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +public class Paginator { + public static final int DEFAULT_PER_PAGE = 14; + + private int perPage = DEFAULT_PER_PAGE; + private @Nullable BaseComponent title; + private IndexedFunction formatter = + (t, i) -> new Component(String.valueOf(t)); + + public Paginator() { + this(DEFAULT_PER_PAGE); + } + + public Paginator(int perPage) { + checkArgument(perPage > 0); + this.perPage = perPage; + } + + public Paginator perPage(int perPage) { + this.perPage = perPage; + return this; + } + + public Paginator title(@Nullable BaseComponent title) { + this.title = title; + return this; + } + + public Paginator entries(IndexedFunction formatter) { + this.formatter = checkNotNull(formatter); + return this; + } + + public void display(CommandSender sender, Collection results, int page) { + display(BukkitAudiences.getAudience(sender), results, page); + } + + public void display(Audience audience, Collection results, int page) { + if(results.isEmpty()) { + audience.sendMessage(new WarningComponent("command.error.emptyResult")); + return; + } + + final int pages = Numbers.divideRoundingUp(results.size(), perPage); + + if(page < 1 || page > pages) { + audience.sendMessage(new WarningComponent("command.error.invalidPage", String.valueOf(page), String.valueOf(pages))); + return; + } + + final int start = perPage * (page - 1); + final int end = Math.min(perPage * page, results.size()); + + audience.sendMessage(header(page, pages)); + + if(results instanceof List) { + List list = (List) results; + for (int index = start; index < end; index++) { + audience.sendMessages(multiEntry(list.get(index), index)); + } + } else { + final Iterator iterator = results.iterator(); + for(int index = Iterators.advance(iterator, start); index < end; index++) { + audience.sendMessages(multiEntry(iterator.next(), index)); + } + } + } + + public BaseComponent header(int page, int pages) { + final Component c = new Component(ChatColor.GRAY); + final BaseComponent title = title(); + if(title != null) { + c.extra(title, ChatColor.BLUE); + } + c.extra(" (") + .extra(new TranslatableComponent("pageHeader", + new Component(String.valueOf(page), ChatColor.WHITE), + new Component(String.valueOf(pages), ChatColor.WHITE) + )) + .extra(")"); + return new HeaderComponent(c); + } + + protected @Nullable BaseComponent title() { + return title; + } + + protected BaseComponent entry(T entry, int index) { + return formatter.apply(entry, index); + } + + protected List multiEntry(T entry, int index) { + return ImmutableList.of(entry(entry, index)); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/PartialNameRenderer.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/PartialNameRenderer.java new file mode 100644 index 0000000..06d7dc6 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/PartialNameRenderer.java @@ -0,0 +1,20 @@ +package tc.oc.commons.bukkit.chat; + +import net.md_5.bungee.api.chat.BaseComponent; +import tc.oc.commons.bukkit.nick.Identity; + +/** + * Renders some part of a player's name, from {@link Identity}s and {@link NameType}s + */ +public interface PartialNameRenderer { + + /** + * Get a legacy display name + */ + String getLegacyName(Identity identity, NameType type); + + /** + * Get a component display name + */ + BaseComponent getComponentName(Identity identity, NameType type); +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/PlayerComponent.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/PlayerComponent.java new file mode 100644 index 0000000..e82c187 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/PlayerComponent.java @@ -0,0 +1,76 @@ +package tc.oc.commons.bukkit.chat; + +import java.util.List; +import java.util.Objects; + +import net.md_5.bungee.api.chat.BaseComponent; +import tc.oc.commons.bukkit.nick.Identity; +import tc.oc.commons.core.chat.ImmutableComponent; +import tc.oc.commons.core.util.Utils; + +import static com.google.common.base.Preconditions.checkNotNull; + +/** + * A component that renders as a player's name. + * + * The "fancy" flag determines whether the name will include + * flair and other decorations. + * + * The "big" flag shows the player's nickname after their + * real name when they are nicked and the viewer can see + * through it. + * + * A non-fancy, non-big name has color and nothing else. + */ +public class PlayerComponent extends ImmutableComponent { + + private final Identity identity; + private final NameStyle style; + + public PlayerComponent(Identity identity, NameStyle style) { + this.identity = checkNotNull(identity); + this.style = checkNotNull(style); + } + + public PlayerComponent(Identity identity) { + this(identity, NameStyle.VERBOSE); + } + + public PlayerComponent(PlayerComponent original) { + this(original.identity, original.style); + } + + public Identity getIdentity() { + return identity; + } + + public NameStyle getStyle() { + return style; + } + + @Override + public BaseComponent duplicate() { + return new PlayerComponent(this); + } + + @Override + protected void toStringFirst(List fields) { + super.toStringFirst(fields); + fields.add("identity=" + identity); + fields.add("style=" + style); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), identity, style); + } + + @Override + protected boolean equals(BaseComponent obj) { + return Utils.equals(PlayerComponent.class, this, obj, that -> + identity.equals(that.getIdentity()) && + style.equals(that.getStyle()) && + super.equals(that) + ); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/PlayerComponentRenderer.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/PlayerComponentRenderer.java new file mode 100644 index 0000000..b9bbb83 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/PlayerComponentRenderer.java @@ -0,0 +1,27 @@ +package tc.oc.commons.bukkit.chat; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import net.md_5.bungee.api.chat.BaseComponent; +import org.bukkit.command.CommandSender; + +/** + * Ties together the {@link ComponentRenderer} and {@link NameRenderer} systems to + * convert {@link PlayerComponent}s into primitive components. + */ +@Singleton +public class PlayerComponentRenderer extends BaseComponentRenderer { + + private final CachingNameRenderer nameRenderer; + + @Inject PlayerComponentRenderer(CachingNameRenderer nameRenderer) { + this.nameRenderer = nameRenderer; + } + + @Override + public BaseComponent renderContent(ComponentRenderContext context, PlayerComponent original, CommandSender viewer) { + return nameRenderer.getComponentName(original.getIdentity(), + new NameType(original.getStyle(), original.getIdentity(), viewer)); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/StyledNameFunction.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/StyledNameFunction.java new file mode 100644 index 0000000..fb014ad --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/StyledNameFunction.java @@ -0,0 +1,17 @@ +package tc.oc.commons.bukkit.chat; + +import com.google.common.base.Function; +import net.md_5.bungee.api.chat.BaseComponent; + +public class StyledNameFunction implements Function { + private final NameStyle style; + + public StyledNameFunction(NameStyle style) { + this.style = style; + } + + @Override + public BaseComponent apply(Named named) { + return named.getStyledName(style); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/TemplateComponent.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/TemplateComponent.java new file mode 100644 index 0000000..41d9d46 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/TemplateComponent.java @@ -0,0 +1,37 @@ +package tc.oc.commons.bukkit.chat; + +import java.util.List; + +import com.google.common.collect.ImmutableList; +import net.md_5.bungee.api.chat.BaseComponent; +import org.bukkit.command.CommandSender; +import tc.oc.commons.bukkit.localization.MessageTemplate; +import tc.oc.commons.core.chat.Component; +import tc.oc.commons.core.chat.Components; +import tc.oc.commons.core.chat.ImmutableComponent; + +public class TemplateComponent extends ImmutableComponent implements RenderableComponent { + + private final MessageTemplate message; + private final List with; + + public TemplateComponent(MessageTemplate message, BaseComponent... with) { + this(message, ImmutableList.copyOf(with)); + } + + public TemplateComponent(MessageTemplate message, List with) { + this.message = message; + this.with = ImmutableList.copyOf(with); + } + + @Override + public BaseComponent duplicate() { + return new TemplateComponent(message, with); + } + + @Override + public BaseComponent render(ComponentRenderContext context, CommandSender viewer) { + return new Component(Components.format(message.format(viewer), + context.render(with, viewer))); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/TextComponentRenderer.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/TextComponentRenderer.java new file mode 100644 index 0000000..cce9974 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/TextComponentRenderer.java @@ -0,0 +1,14 @@ +package tc.oc.commons.bukkit.chat; + +import javax.inject.Singleton; + +import net.md_5.bungee.api.chat.BaseComponent; +import net.md_5.bungee.api.chat.TextComponent; +import org.bukkit.command.CommandSender; + +@Singleton +public class TextComponentRenderer extends BaseComponentRenderer { + @Override protected BaseComponent renderContent(ComponentRenderContext context, TextComponent original, CommandSender viewer) { + return original; + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/TranslatableComponentRenderer.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/TranslatableComponentRenderer.java new file mode 100644 index 0000000..671b415 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/TranslatableComponentRenderer.java @@ -0,0 +1,43 @@ +package tc.oc.commons.bukkit.chat; + +import net.md_5.bungee.api.chat.BaseComponent; +import net.md_5.bungee.api.chat.TranslatableComponent; +import org.bukkit.command.CommandSender; +import tc.oc.commons.bukkit.localization.Translator; +import tc.oc.commons.core.chat.Component; +import tc.oc.commons.core.chat.Components; + +import java.text.MessageFormat; +import java.util.List; +import java.util.Optional; +import javax.inject.Inject; +import javax.inject.Singleton; + +@Singleton +public class TranslatableComponentRenderer extends BaseComponentRenderer { + + private final Translator translations; + + @Inject TranslatableComponentRenderer(Translator translations) { + this.translations = translations; + } + + @Override + public BaseComponent renderContent(ComponentRenderContext context, TranslatableComponent original, CommandSender viewer) { + final List with = context.render(original.getWith(), viewer); + final Optional pattern = translations.pattern(original.getTranslate(), viewer); + + if(pattern.isPresent()) { + // Found a TranslatableComponent with one of our keys + return new Component(Components.format(pattern.get(), with)); + } else if(with != original.getWith()) { + // Not our key, but something in with was replaced + final TranslatableComponent replacement = new TranslatableComponent(original.getTranslate()); + replacement.setWith(with); + return replacement; + } else { + // Nothing was replaced + return original; + } + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/UserTextComponent.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/UserTextComponent.java new file mode 100644 index 0000000..3a4d6ba --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/UserTextComponent.java @@ -0,0 +1,70 @@ +package tc.oc.commons.bukkit.chat; + +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import net.md_5.bungee.api.ChatColor; +import net.md_5.bungee.api.chat.ClickEvent; +import net.md_5.bungee.api.chat.TextComponent; +import tc.oc.commons.bukkit.nick.Identity; +import tc.oc.commons.core.chat.Component; + +/** + * Subclass of {@link TextComponent} used to display user-entered text. + * + * Current features: + * - Autolinking + * + * Also stores the {@link Identity} of the author, but we currently don't + * use that for anything. + * + * TODO: Possible future features include linking/decorating player names, + * masking offensive language, markdown formatting. + */ +public class UserTextComponent extends TextComponent { + + // Source: http://stackoverflow.com/a/5713866/6342 + private static final Pattern URL = Pattern.compile("(?:^|[\\W])(https?://|www\\.)" + + "(([\\w\\-]+\\.)+?([\\w\\-.~]+/?)*" + + "[\\p{Alnum}.,%_=?&#\\-+()\\[\\]\\*$~@!:/{};']*)", + Pattern.CASE_INSENSITIVE | Pattern.MULTILINE | Pattern.DOTALL); + final Optional author; + final Component rendered = new Component(); + + public UserTextComponent(Optional author, String text) { + super(text); + this.author = author; + + final Matcher matcher = URL.matcher(text); + int textStart = 0; + + while(matcher.find()) { + final int linkStart = matcher.start(1); + final int linkEnd = matcher.end(); + + if(linkStart > textStart) { + rendered.extra(text.substring(textStart, linkStart)); + } + + final String link = text.substring(linkStart, linkEnd); + final String url = link.startsWith("http") ? link + : "http://" + link; + rendered.extra(new Component(link, ChatColor.BLUE, ChatColor.UNDERLINE).clickEvent(ClickEvent.Action.OPEN_URL, url)); + + textStart = linkEnd; + } + + if(textStart < text.length()) { + rendered.extra(text.substring(textStart)); + } + } + + public UserTextComponent(Identity author, String text) { + this(Optional.of(author), text); + } + + public UserTextComponent(String text) { + this(Optional.empty(), text); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/UserTextComponentRenderer.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/UserTextComponentRenderer.java new file mode 100644 index 0000000..7f1967b --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/UserTextComponentRenderer.java @@ -0,0 +1,11 @@ +package tc.oc.commons.bukkit.chat; + +import net.md_5.bungee.api.chat.BaseComponent; +import org.bukkit.command.CommandSender; + +public class UserTextComponentRenderer implements ComponentRenderer { + @Override + public BaseComponent render(ComponentRenderContext context, UserTextComponent original, CommandSender viewer) { + return original.rendered; + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/UserURI.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/UserURI.java new file mode 100644 index 0000000..d54c042 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/chat/UserURI.java @@ -0,0 +1,37 @@ +package tc.oc.commons.bukkit.chat; + +import java.net.URI; +import java.util.Optional; + +import org.bukkit.command.CommandSender; +import tc.oc.commons.bukkit.nick.Identity; + +public class UserURI implements Renderable { + + private final Optional identity; + private final String path; + + public UserURI(Optional identity, String path) { + this.identity = identity; + this.path = path.length() == 0 || path.startsWith("/") ? path : "/" + path; + } + + public UserURI(Optional identity) { + this(identity, ""); + } + + public UserURI(String path) { + this(Optional.empty(), path); + } + + public UserURI() { + this(Optional.empty()); + } + + @Override + public URI render(ComponentRenderContext context, CommandSender viewer) { + return Links.homeUriSafe("/" + identity.map(id -> id.getName(viewer)) + .orElseGet(viewer::getName) + + path); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/commands/CommandUtils.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/commands/CommandUtils.java new file mode 100644 index 0000000..3360c65 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/commands/CommandUtils.java @@ -0,0 +1,201 @@ +package tc.oc.commons.bukkit.commands; + +import java.time.Duration; +import java.time.format.DateTimeParseException; +import java.util.List; +import java.util.Optional; +import javax.annotation.Nullable; + +import com.sk89q.minecraft.util.commands.CommandContext; +import com.sk89q.minecraft.util.commands.CommandException; +import com.sk89q.minecraft.util.commands.CommandPermissionsException; +import net.md_5.bungee.api.chat.BaseComponent; +import net.md_5.bungee.api.chat.TranslatableComponent; +import org.bukkit.Bukkit; +import org.bukkit.ChatColor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.bukkit.permissions.Permissible; +import org.bukkit.permissions.Permission; +import tc.oc.api.docs.PlayerId; +import tc.oc.api.docs.Server; +import tc.oc.commons.bukkit.chat.ComponentRenderers; +import tc.oc.commons.bukkit.localization.Translations; +import tc.oc.commons.core.chat.Component; +import tc.oc.commons.core.commands.TranslatableCommandException; +import tc.oc.commons.core.util.TimeUtils; + +public abstract class CommandUtils { + + public static final String CONSOLE_DISPLAY_NAME = ChatColor.GOLD + "❖" + ChatColor.AQUA + "Console"; + public static final BaseComponent CONSOLE_COMPONENT_NAME = new Component(new Component("❖").color(net.md_5.bungee.api.ChatColor.GOLD), + new Component("Console").color(net.md_5.bungee.api.ChatColor.AQUA)); + + public static Optional flag(CommandContext args, char flag) { + return Optional.ofNullable(args.getFlag(flag)); + } + + public static CommandException newCommandException(CommandSender sender, BaseComponent message) { + return new CommandException(ComponentRenderers.toLegacyText(message, sender)); + } + + public static Player senderToPlayer(CommandSender sender) throws CommandException { + if(sender instanceof Player) { + return (Player) sender; + } else { + throw new CommandException(ComponentRenderers.toLegacyText(new TranslatableComponent("command.onlyPlayers"), sender)); + } + } + + /** + * Get an online {@link Player} by exact name + */ + public static Player getPlayer(CommandContext args, CommandSender sender, int index) throws CommandException { + if(args.argsLength() > index) { + Player player = sender.getServer().getPlayerExact(args.getString(index), sender); + if(player == null) throw new CommandException(ComponentRenderers.toLegacyText(new TranslatableComponent("command.playerNotFound"), sender)); + return player; + } else { + throw new CommandException(ComponentRenderers.toLegacyText(new TranslatableComponent("command.specifyPlayer"), sender)); + } + } + + /** + * Get an online {@link Player} by exact name, defaulting to sender + */ + public static Player getPlayerOrSelf(CommandContext args, CommandSender sender, int index) throws CommandException { + return senderToPlayer(getCommandSenderOrSelf(args, sender, index)); + } + + /** + * Get an online {@link CommandSender} by exact name, defaulting to sender + */ + public static CommandSender getCommandSenderOrSelf(CommandContext args, CommandSender sender, int index) throws CommandException { + if(args.argsLength() > index) { + Player player = sender.getServer().getPlayerExact(args.getString(index), sender); + if(player == null) throw new CommandException(ComponentRenderers.toLegacyText(new TranslatableComponent("command.playerNotFound"), sender)); + return player; + } else { + return sender; + } + } + + /** + * Get an online {@link Player} by partial name. + * @throws CommandException if the name does not match any player, or matches multiple players + */ + public static Player findOnlinePlayer(CommandContext args, CommandSender sender, int index) throws CommandException { + if(args.argsLength() > index) { + String name = args.getString(index); + List players = sender.getServer().matchPlayer(name, sender); + switch(players.size()) { + case 0: throw new CommandException(Translations.get().t("command.playerNotFound", sender)); + case 1: return players.get(0); + default: throw new CommandException(Translations.get().t("command.multiplePlayersFound", sender)); + } + } else { + throw new CommandException(Translations.get().t("command.specifyPlayer", sender)); + } + } + + public static void assertPermission(Permissible permissible, String permission) throws CommandPermissionsException { + if(!permissible.hasPermission(permission)) { + throw new CommandPermissionsException(); + } + } + + public static void assertPermission(Permissible permissible, Permission permission) throws CommandPermissionsException { + if(!permissible.hasPermission(permission)) { + throw new CommandPermissionsException(); + } + } + + public static int getInteger(CommandContext args, CommandSender sender, int index, int def) throws CommandException { + try { + return args.getInteger(index, def); + } + catch(NumberFormatException e) { + throw new CommandException(ComponentRenderers.toLegacyText(new TranslatableComponent("command.error.invalidNumber", args.getString(index)), sender)); + } + } + + public static @Nullable Duration getDuration(CommandContext args, int index) throws CommandException { + return getDuration(args, index, null); + } + + public static Duration getDuration(CommandContext args, int index, Duration def) throws CommandException { + return args.argsLength() > index ? getDuration(args.getString(index), null) : def; + } + + public static @Nullable Duration getDuration(@Nullable String text) throws CommandException { + return getDuration(text, null); + } + + public static Duration getDuration(@Nullable String text, Duration def) throws CommandException { + if(text == null) { + return def; + } else { + try { + return TimeUtils.parseDuration("P" + text); + } catch(DateTimeParseException e) { + throw new TranslatableCommandException("command.error.invalidTimePeriod", text); + } + } + } + + public static String getDisplayName(CommandSender target) { + return getDisplayName(target, null); + } + + public static String getDisplayName(CommandSender target, CommandSender viewer) { + if(target instanceof Player) { + return ((Player) target).getDisplayName(viewer); + } else { + return CONSOLE_DISPLAY_NAME; + } + } + + public static String getDisplayName(@Nullable PlayerId target) { + return getDisplayName(target, null); + } + + public static String getDisplayName(@Nullable PlayerId target, CommandSender viewer) { + if(target == null) { + return CONSOLE_DISPLAY_NAME; + } else { + Player targetPlayer = Bukkit.getPlayerExact(target.username(), viewer); + if(targetPlayer == null) { + return ChatColor.DARK_AQUA + target.username(); + } else { + return targetPlayer.getDisplayName(viewer); + } + } + } + + public static String getDisplayName(@Nullable String username) { + return getDisplayName(username, null); + } + + public static String getDisplayName(@Nullable String username, CommandSender viewer) { + if(username == null || username.trim().length() == 0 || username.trim().equalsIgnoreCase("CONSOLE")) { + return CONSOLE_DISPLAY_NAME; + } else { + Player targetPlayer = Bukkit.getPlayerExact(username, viewer); + if(targetPlayer == null) { + return ChatColor.DARK_AQUA + username; + } else { + return targetPlayer.getDisplayName(viewer); + } + } + } + + public static String formatServerPrefix(Server server) { + return ChatColor.WHITE + "[" + + ChatColor.GOLD + server.name() + + ChatColor.WHITE + "]"; + } + + public static void notEnoughArguments(CommandSender sender) throws CommandException { + throw new CommandException(Translations.get().t("command.error.notEnoughArguments", sender)); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/commands/PermissionCommands.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/commands/PermissionCommands.java new file mode 100644 index 0000000..b3a1332 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/commands/PermissionCommands.java @@ -0,0 +1,138 @@ +package tc.oc.commons.bukkit.commands; + +import com.sk89q.minecraft.util.commands.Command; +import com.sk89q.minecraft.util.commands.CommandContext; +import com.sk89q.minecraft.util.commands.CommandException; +import com.sk89q.minecraft.util.commands.CommandPermissions; +import com.sk89q.minecraft.util.commands.NestedCommand; +import org.bukkit.ChatColor; +import org.bukkit.command.CommandSender; +import org.bukkit.permissions.Permission; +import org.bukkit.permissions.PermissionAttachmentInfo; +import org.bukkit.plugin.PluginManager; +import tc.oc.api.util.Permissions; +import tc.oc.commons.core.commands.Commands; +import tc.oc.commons.core.commands.NestedCommands; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map; + +public class PermissionCommands implements NestedCommands { + public static class Parent implements Commands { + @Command( + aliases = {"permission", "perm"}, + desc = "Commands to query permissions", + min = 1, + max = -1 + ) + @NestedCommand({PermissionCommands.class}) + @CommandPermissions(Permissions.DEVELOPER) + public void perm() { + } + } + + void sendPermissionInfo(String name, CommandSender sender) { + PluginManager pm = sender.getServer().getPluginManager(); + Permission permission = pm.getPermission(name); + + if(permission == null) { + sender.sendMessage(ChatColor.RED + "Permission " + name + " is unregistered"); + } else { + sender.sendMessage(ChatColor.GOLD + "Permission " + ChatColor.WHITE + permission.getName()); + sender.sendMessage(ChatColor.WHITE.toString() + ChatColor.ITALIC + permission.getDescription()); + sender.sendMessage(ChatColor.GOLD + "Default: " + ChatColor.WHITE + permission.getDefault()); + + boolean first = true; + for(Permission parent : pm.getPermissions()) { + Boolean value = parent.getChildren().get(permission.getName()); + if(value != null) { + if(first) { + first = false; + sender.sendMessage(ChatColor.GOLD + "Parents:"); + } + + if(value) { + sender.sendMessage(ChatColor.GREEN + " +" + parent.getName()); + } else { + sender.sendMessage(ChatColor.RED + " -" + parent.getName()); + } + } + } + + first = true; + for(Map.Entry child : permission.getChildren().entrySet()) { + if(first) { + first = false; + sender.sendMessage(ChatColor.GOLD + "Children:"); + } + + if(child.getValue()) { + sender.sendMessage(ChatColor.GREEN + " +" + child.getKey()); + } else { + sender.sendMessage(ChatColor.RED + " -" + child.getKey()); + } + } + } + } + + @Command( + aliases = {"info"}, + desc = "Get detailed info about a permission", + usage = "", + min = 1, + max = 1 + ) + public void info(CommandContext args, CommandSender sender) throws CommandException { + sendPermissionInfo(args.getString(0), sender); + } + + @Command( + aliases = {"test"}, + desc = "Test for a specific permission", + usage = " [player]", + min = 1, + max = 2 + ) + public void test(CommandContext args, CommandSender sender) throws CommandException { + CommandSender player = CommandUtils.getCommandSenderOrSelf(args, sender, 1); + String perm = args.getString(0); + if(player.hasPermission(perm)) { + sender.sendMessage(ChatColor.GREEN + player.getName() + " has permission " + perm); + } else { + sender.sendMessage(ChatColor.RED + player.getName() + " does NOT have permission " + perm); + } + } + + @Command( + aliases = {"list"}, + desc = "List all permissions", + usage = "[player] [prefix]", + min = 0, + max = 2 + ) + public void list(CommandContext args, CommandSender sender) throws CommandException { + CommandSender player = CommandUtils.getCommandSenderOrSelf(args, sender, 0); + String prefix = args.getString(1, ""); + + sender.sendMessage(ChatColor.WHITE + "Permissions for " + player.getName() + ":"); + + List perms = new ArrayList<>(player.getEffectivePermissions()); + Collections.sort(perms, new Comparator() { + @Override + public int compare(PermissionAttachmentInfo a, PermissionAttachmentInfo b) { + return a.getPermission().compareTo(b.getPermission()); + } + }); + + for(PermissionAttachmentInfo perm : perms) { + if(perm.getPermission().startsWith(prefix)) { + sender.sendMessage((perm.getValue() ? ChatColor.GREEN : ChatColor.RED) + + " " + perm.getPermission() + + (perm.getAttachment() == null ? "" : " (" + perm.getAttachment().getPlugin().getName() + ")")); + } + } + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/commands/PrettyPaginatedResult.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/commands/PrettyPaginatedResult.java new file mode 100644 index 0000000..08e779f --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/commands/PrettyPaginatedResult.java @@ -0,0 +1,46 @@ +package tc.oc.commons.bukkit.commands; + +import com.google.common.base.Preconditions; +import com.sk89q.minecraft.util.commands.CommandException; +import com.sk89q.minecraft.util.commands.WrappedCommandSender; +import com.sk89q.minecraft.util.pagination.PaginatedResult; +import net.md_5.bungee.api.ChatColor; +import tc.oc.commons.core.chat.ChatUtils; + +import java.util.List; + +public abstract class PrettyPaginatedResult extends PaginatedResult { + protected final String header; + + public PrettyPaginatedResult(String header) { + this(header, 8); + } + + public PrettyPaginatedResult(String header, int resultsPerPage) { + super(resultsPerPage); + this.header = Preconditions.checkNotNull(header, "header"); + } + + @Override + public void display(WrappedCommandSender sender, List results, int page) throws CommandException { + if(results.isEmpty()) { + sender.sendMessage(formatEmpty()); + } else { + super.display(sender, results, page); + } + } + + @Override + public String formatHeader(int page, int totalPages) { + ChatColor dashColor = ChatColor.BLUE; + ChatColor textColor = ChatColor.DARK_AQUA; + ChatColor highlight = ChatColor.AQUA; + + String message = this.header + textColor + " (" + highlight + page + textColor + " of " + highlight + totalPages + textColor + ")"; + return ChatUtils.horizontalLineHeading(message, dashColor, ChatUtils.MAX_CHAT_WIDTH); + } + + public String formatEmpty() { + return ChatColor.RED + "No results"; + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/commands/ServerCommands.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/commands/ServerCommands.java new file mode 100644 index 0000000..96337f9 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/commands/ServerCommands.java @@ -0,0 +1,170 @@ +package tc.oc.commons.bukkit.commands; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Set; +import javax.inject.Inject; + +import com.sk89q.minecraft.util.commands.Command; +import com.sk89q.minecraft.util.commands.CommandContext; +import com.sk89q.minecraft.util.commands.CommandException; +import net.md_5.bungee.api.ChatColor; +import net.md_5.bungee.api.chat.BaseComponent; +import net.md_5.bungee.api.chat.TranslatableComponent; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import tc.oc.api.docs.Server; +import tc.oc.api.docs.virtual.ServerDoc; +import tc.oc.api.servers.ServerStore; +import tc.oc.commons.bukkit.chat.Audiences; +import tc.oc.commons.bukkit.chat.Paginator; +import tc.oc.commons.bukkit.chat.WarningComponent; +import tc.oc.commons.bukkit.format.ServerFormatter; +import tc.oc.commons.bukkit.teleport.Teleporter; +import tc.oc.commons.core.chat.Audience; +import tc.oc.commons.core.chat.Component; +import tc.oc.commons.core.commands.Commands; +import tc.oc.commons.core.commands.TranslatableCommandException; +import tc.oc.commons.core.formatting.StringUtils; + +public class ServerCommands implements Commands { + + private final Server localServer; + private final ServerStore serverStore; + private final ServerFormatter formatter = ServerFormatter.light; + private final Teleporter teleporter; + private final Audiences audiences; + + private static final Comparator FULLNESS = Comparator.comparing(Server::num_online).reversed(); + + @Inject ServerCommands(Server localServer, ServerStore serverStore, Teleporter teleporter, Audiences audiences) { + this.localServer = localServer; + this.serverStore = serverStore; + this.teleporter = teleporter; + this.audiences = audiences; + } + + private BaseComponent format(Server server) { + final Component c = new Component(formatter.nameWithDatacenter(server)) + .extra(" ") + .extra(formatter.playerCounts(server, true)); + if(server.role() == ServerDoc.Role.PGM && server.current_match() != null) { + c.extra(" ") + .extra(formatter.matchTime(server.current_match())) + .extra(" ") + .extra(new Component(server.current_match().map().name(), ChatColor.AQUA)); + } + return c; + } + + @Command( + aliases = { "servers", "srvs" }, + desc = "Show a listing of all servers on the network", + usage = "[page]", + min = 0, + max = 1 + ) + public void servers(final CommandContext args, final CommandSender sender) throws CommandException { + final List servers = new ArrayList<>(serverStore.subset(teleporter::isVisible)); + Collections.sort(servers, FULLNESS); + + new Paginator() { + @Override + protected BaseComponent title() { + return new TranslatableComponent("command.servers.title"); + } + + @Override + protected BaseComponent entry(Server server, int index) { + return format(server); + } + }.display(sender, servers, args.getInteger(0, 1)); + } + + @Command( + aliases = { "server", "srv" }, + desc = "Show the name of this server or connect to a different server", + usage = "[-d datacenter] [name]", + flags = "bd:" + ) + public List server(CommandContext args, final CommandSender sender) throws CommandException { + if(args.getSuggestionContext() != null) { + return StringUtils.complete(args.getJoinedStrings(0), + serverStore.all() + .filter(teleporter::isConnectable) + .map(Server::name)); + } + + // Show current server + if(args.argsLength() == 0) { + teleporter.showCurrentServer(sender); + return null; + } + + final Player player = CommandUtils.senderToPlayer(sender); + + // Search by bungee_name + if(args.hasFlag('b')) { + final Server byBungee = serverStore.tryBungeeName(args.getJoinedStrings(0)); + if(byBungee == null) { + throw new TranslatableCommandException("command.serverNotFound"); + } + teleporter.remoteTeleport(player, byBungee); + return null; + } + + // Search by name/datacenter + // If they don't have the X-DC perm, parse the first arg as part of the + // server name, so normal players can do "/server ghost squadron" + final String datacenter, name; + if(args.argsLength() > 1 && !args.hasFlag('d') && player.hasPermission(Teleporter.CROSS_DATACENTER_PERMISSION)) { + datacenter = args.getString(0).toUpperCase(); + name = args.getJoinedStrings(1); + } else { + datacenter = args.getFlag('d', localServer.datacenter()); + name = args.getJoinedStrings(0); + } + + // Special aliases for the lobby + if(name.equals("lobby") || name.equals("hub")) { + teleporter.remoteTeleport(player, datacenter, null, null); + return null; + } + + final Set connectable = serverStore.subset(teleporter::isConnectable); + final List partial = new ArrayList<>(); + for(Server server : connectable) { + if(server.name().equalsIgnoreCase(name)) { + teleporter.remoteTeleport(player, server); + return null; + } + if(StringUtils.startsWithIgnoreCase(server.name(), name)) { + partial.add(server); + } + } + + final Audience audience = audiences.get(sender); + audience.sendMessage(new WarningComponent("command.serverNotFound")); + + // If there were no more than 3 partial matches, show them + if(partial.size() <= 3) { + Collections.sort(partial, FULLNESS); + for(Server server : partial) { + audience.sendMessage(format(server)); + } + } + return null; + } + + @Command( + aliases = {"hub", "lobby"}, + desc = "Teleport to the lobby", + min = 0, + max = 1 + ) + public void hub(final CommandContext args, CommandSender sender) throws CommandException { + teleporter.sendToLobby(CommandUtils.senderToPlayer(sender), args.getString(0, null)); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/commands/ServerVisibilityCommands.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/commands/ServerVisibilityCommands.java new file mode 100644 index 0000000..e1aa965 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/commands/ServerVisibilityCommands.java @@ -0,0 +1,137 @@ +package tc.oc.commons.bukkit.commands; + +import java.util.EnumSet; +import java.util.logging.Logger; +import javax.inject.Inject; + +import com.google.common.util.concurrent.ListenableFuture; +import com.sk89q.minecraft.util.commands.Command; +import com.sk89q.minecraft.util.commands.CommandContext; +import com.sk89q.minecraft.util.commands.CommandException; +import com.sk89q.minecraft.util.commands.CommandPermissions; +import org.bukkit.ChatColor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerQuitEvent; +import tc.oc.api.docs.Server; +import tc.oc.api.docs.virtual.ServerDoc; +import tc.oc.api.minecraft.MinecraftService; +import tc.oc.minecraft.scheduler.SyncExecutor; +import tc.oc.commons.bukkit.event.WhitelistStateChangeEvent; +import tc.oc.commons.bukkit.whitelist.Whitelist; +import tc.oc.commons.core.commands.CommandFutureCallback; +import tc.oc.commons.core.commands.Commands; +import tc.oc.commons.core.logging.Loggers; + +public class ServerVisibilityCommands implements Listener, Commands { + + private static final String VISIBILITY_PERMISSION = "server.visibility"; + private static final EnumSet OPTIONS = EnumSet.of(ServerDoc.Visibility.PUBLIC, ServerDoc.Visibility.UNLISTED); + + private final Logger logger; + private final MinecraftService minecraftService; + private final SyncExecutor syncExecutor; + private final Whitelist whitelist; + + @Inject ServerVisibilityCommands(Loggers loggers, MinecraftService minecraftService, SyncExecutor syncExecutor, Whitelist whitelist) { + this.whitelist = whitelist; + this.logger = loggers.get(getClass()); + this.minecraftService = minecraftService; + this.syncExecutor = syncExecutor; + } + + private static String coloredVisibility(ServerDoc.Visibility visibility) { + switch(visibility) { + case PRIVATE: return ChatColor.BLUE + "private"; + case UNLISTED: return ChatColor.YELLOW + "unlisted"; + case PUBLIC: return ChatColor.GREEN + "public"; + default: return ChatColor.RED + "unknown"; + } + } + + private static void reportVisibility(CommandSender sender, ServerDoc.Visibility visibility) { + sender.sendMessage("Server visibility: " + coloredVisibility(visibility)); + } + + // Too bad Gson refuses to serialize anonymous classes + private static class Info implements ServerDoc.Visible { + private final ServerDoc.Visibility visibility; + public Info(ServerDoc.Visibility visibility) {this.visibility = visibility; } + @Override public ServerDoc.Visibility visibility() { return visibility; } + } + + private ListenableFuture setVisibility(final ServerDoc.Visibility visibility) { + return minecraftService.updateLocalServer(new Info(visibility)); + } + + @Command( + aliases = { "visibility" }, + desc = "Show or change the visibility type of this server", + usage = "[public|unlisted]", + min = 0, + max = 1 + ) + @CommandPermissions(VISIBILITY_PERMISSION) + public void visibility(final CommandContext args, final CommandSender sender) throws CommandException { + final Server local = minecraftService.getLocalServer(); + if(args.argsLength() < 1) { + reportVisibility(sender, local.visibility()); + } else { + final ServerDoc.Visibility visibility; + try { + visibility = ServerDoc.Visibility.valueOf(args.getString(0).toUpperCase()); + if(!OPTIONS.contains(visibility)) throw new IllegalArgumentException(); + } catch(IllegalArgumentException e) { + throw new CommandException("Invalid visibility type '" + args.getString(0) + "'"); + } + + if(visibility == ServerDoc.Visibility.PUBLIC && whitelist.isEnabled()) { + throw new CommandException("Cannot set visibility to 'public' while whitelist is enabled"); + } + + syncExecutor.callback( + setVisibility(visibility), + CommandFutureCallback.onSuccess(sender, args, response -> { + logger.info("Server visibility set to " + response.visibility() + " by " + sender.getName()); + reportVisibility(sender, response.visibility()); + }) + ); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void hideAbandonedServer(PlayerQuitEvent event) { + final Server local = minecraftService.getLocalServer(); + + if(local.startup_visibility() != ServerDoc.Visibility.PUBLIC && // If server was initially not public and + local.visibility() == ServerDoc.Visibility.PUBLIC && // server was made public and + event.getPlayer().hasPermission(VISIBILITY_PERMISSION)) { // someone with perms to do that is leaving... + + // ...check if there is still someone online with that permission + for(Player player : event.getPlayer().getServer().getOnlinePlayers()) { + // If someone else with perms is online, we're cool + if(player != event.getPlayer() && player.hasPermission(VISIBILITY_PERMISSION)) return; + } + + // If nobody with perms is online, make the server non-public again + logger.info("Reverting server visibility to " + local.startup_visibility() + " because nobody with permissions is online"); + setVisibility(local.startup_visibility()); + } + } + + @EventHandler + public void hideWhitelistedServer(WhitelistStateChangeEvent event) { + final Server local = minecraftService.getLocalServer(); + + if(local.startup_visibility() != ServerDoc.Visibility.PUBLIC && + local.visibility() == ServerDoc.Visibility.PUBLIC && + event.isEnabled()) { + + logger.info("Reverting server visibility to " + local.startup_visibility() + " because whitelist is enabled"); + setVisibility(local.startup_visibility()); + } + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/commands/SkinCommands.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/commands/SkinCommands.java new file mode 100644 index 0000000..128f67a --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/commands/SkinCommands.java @@ -0,0 +1,76 @@ +package tc.oc.commons.bukkit.commands; + +import com.sk89q.minecraft.util.commands.Command; +import com.sk89q.minecraft.util.commands.CommandContext; +import com.sk89q.minecraft.util.commands.CommandException; +import com.sk89q.minecraft.util.commands.CommandPermissions; +import com.sk89q.minecraft.util.commands.NestedCommand; +import org.bukkit.ChatColor; +import org.bukkit.Skin; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import tc.oc.commons.core.commands.Commands; +import tc.oc.commons.core.commands.NestedCommands; +import tc.oc.commons.core.plugin.PluginFacet; + +public class SkinCommands implements NestedCommands, PluginFacet { + public static class Parent implements Commands, PluginFacet { + @Command( + aliases = {"skin"}, + desc = "Commands to manipulate skins", + min = 1, + max = -1 + ) + @NestedCommand({SkinCommands.class}) + @CommandPermissions("skin.change") + public void skin() { + } + } + + @Command(aliases = {"info"}, + desc = "Dump the encoded data for a player's skin", + usage = "[player]") + public void info(CommandContext args, CommandSender sender) throws CommandException { + Skin skin = CommandUtils.getPlayerOrSelf(args, sender, 0).getSkin(); + sender.sendMessage(ChatColor.BLUE + "Textures: " + ChatColor.WHITE + skin.getData()); + sender.sendMessage(ChatColor.BLUE + "Signature: " + ChatColor.WHITE + skin.getSignature()); + } + + @Command(aliases = {"reset"}, + desc = "Reset a player's skin to their real one", + usage = "[player]") + public void reset(CommandContext args, CommandSender sender) throws CommandException { + Player player = CommandUtils.getPlayerOrSelf(args, sender, 0); + + player.setSkin(null); + sender.sendMessage(ChatColor.WHITE + "Reset the skin of " + player.getDisplayName(sender)); + } + + @Command(aliases = {"clone"}, + desc = "Clone one player's skin to another", + usage = " [target]", + flags = "u") + public void clone(CommandContext args, CommandSender sender) throws CommandException { + Player source = CommandUtils.getPlayer(args, sender, 0); + Player target = CommandUtils.getPlayerOrSelf(args, sender, 1); + + boolean unsigned = args.hasFlag('u'); + Skin skin = source.getSkin(); + if(unsigned) { + skin = new Skin(skin.getData(), null); + } + + target.setSkin(skin); + sender.sendMessage(ChatColor.WHITE + "Cloned " + source.getDisplayName(sender) + ChatColor.WHITE + "'s skin to " + target.getDisplayName(sender)); + } + + @Command(aliases = {"none"}, + desc = "Clear a player's skin, making them steve/alex", + usage = "[player]") + public void none(CommandContext args, CommandSender sender) throws CommandException { + Player player = CommandUtils.getPlayerOrSelf(args, sender, 0); + + player.setSkin(Skin.EMPTY); + sender.sendMessage(ChatColor.WHITE + "Cleared the skin of " + player.getDisplayName(sender)); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/commands/TraceCommands.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/commands/TraceCommands.java new file mode 100644 index 0000000..aec0f2f --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/commands/TraceCommands.java @@ -0,0 +1,140 @@ +package tc.oc.commons.bukkit.commands; + +import java.util.concurrent.atomic.AtomicBoolean; +import javax.inject.Inject; + +import com.sk89q.minecraft.util.commands.Command; +import com.sk89q.minecraft.util.commands.CommandContext; +import com.sk89q.minecraft.util.commands.CommandException; +import com.sk89q.minecraft.util.commands.CommandPermissions; +import com.sk89q.minecraft.util.commands.NestedCommand; +import org.bukkit.Bukkit; +import org.bukkit.command.CommandSender; +import org.bukkit.craftbukkit.event.AsyncClientConnectEvent; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import tc.oc.api.bukkit.users.OnlinePlayers; +import tc.oc.api.util.Permissions; +import tc.oc.commons.bukkit.util.PacketTracer; +import tc.oc.commons.core.commands.Commands; +import tc.oc.commons.core.commands.NestedCommands; + +import static tc.oc.commons.bukkit.commands.CommandUtils.getCommandSenderOrSelf; + +/** + * Packet tracing commands + */ +public class TraceCommands implements NestedCommands, Listener { + + private final OnlinePlayers onlinePlayers; + private final AtomicBoolean traceAll = new AtomicBoolean(false); + + @Inject TraceCommands(OnlinePlayers onlinePlayers) { + this.onlinePlayers = onlinePlayers; + } + + @EventHandler + void onLogin(AsyncClientConnectEvent event) { + if(traceAll.get()) { + PacketTracer.start(event.channel(), event.channel().remoteAddress().toString(), Bukkit.getLogger()); + } + } + + public static class Parent implements Commands { + @Command( + aliases = "trace", + desc = "Packet dumping commands", + min = 1, + max = -1 + ) + @CommandPermissions(Permissions.DEVELOPER) + @NestedCommand(TraceCommands.class) + public void trace(CommandContext args, CommandSender sender) throws CommandException {} + } + + @Command( + aliases = {"on", "start"}, + desc = "Start logging packets", + min = 0, + max = 1 + ) + public void start(CommandContext args, CommandSender sender) throws CommandException { + if(sender instanceof Player || args.argsLength() >= 1) { + final Player player = (Player) getCommandSenderOrSelf(args, sender, 0); + if(PacketTracer.start(player, Bukkit.getLogger())) { + sender.sendMessage("Started packet trace for " + player.getName(sender)); + } + } else if(traceAll.compareAndSet(false, true)) { + onlinePlayers.all().forEach(player -> PacketTracer.start(player, Bukkit.getLogger())); + sender.sendMessage("Started global packet trace"); + } + } + + @Command( + aliases = {"off", "stop"}, + desc = "Stop logging packets", + min = 0, + max = 1 + ) + public void stop(CommandContext args, CommandSender sender) throws CommandException { + if(sender instanceof Player || args.argsLength() >= 1) { + final Player player = (Player) getCommandSenderOrSelf(args, sender, 0); + if(PacketTracer.stop(player)) { + sender.sendMessage("Stopped packet trace for " + player.getName(sender)); + } + } else { + traceAll.set(false); + if(onlinePlayers.all().stream().anyMatch(PacketTracer::stop)) { + sender.sendMessage("Stopped all packet tracing"); + } + } + } + + @Command( + aliases = {"clear", "cl"}, + desc = "Clear all filters", + min = 0, + max = -1 + ) + public void clear(CommandContext args, CommandSender sender) throws CommandException { + PacketTracer.clearFilter(); + sender.sendMessage("Trace filters cleared"); + } + + @Command( + aliases = {"include", "inc"}, + desc = "Include packets in the trace", + min = 0, + max = -1 + ) + public void include(CommandContext args, CommandSender sender) throws CommandException { + filter(args, sender, true); + } + + @Command( + aliases = {"exclude", "ex"}, + desc = "Exclude packets from the trace", + min = 0, + max = -1 + ) + public void exclude(CommandContext args, CommandSender sender) throws CommandException { + filter(args, sender, false); + } + + private static void filter(CommandContext args, CommandSender sender, boolean include) throws CommandException { + if(PacketTracer.getDefaultInclude() == include) { + PacketTracer.clearFilter(); + PacketTracer.setDefaultInclude(!include); + } + + for(String name : args.getSlice(1)) { + Class type = PacketTracer.findPacketType(name); + if(type == null) { + throw new CommandException("No packet named '" + name + "'"); + } + PacketTracer.filter(type, include); + sender.sendMessage((include ? "Including" : "Excluding") + " packet " + type.getSimpleName()); + } + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/commands/UserCommands.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/commands/UserCommands.java new file mode 100644 index 0000000..157b912 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/commands/UserCommands.java @@ -0,0 +1,125 @@ +package tc.oc.commons.bukkit.commands; + +import javax.inject.Inject; + +import com.sk89q.minecraft.util.commands.Command; +import com.sk89q.minecraft.util.commands.CommandContext; +import com.sk89q.minecraft.util.commands.CommandException; +import com.sk89q.minecraft.util.commands.CommandPermissions; +import net.md_5.bungee.api.ChatColor; +import net.md_5.bungee.api.chat.BaseComponent; +import net.md_5.bungee.api.chat.TranslatableComponent; +import org.bukkit.command.CommandSender; +import tc.oc.api.bukkit.users.Users; +import tc.oc.api.docs.PlayerId; +import tc.oc.api.minecraft.MinecraftService; +import tc.oc.minecraft.scheduler.SyncExecutor; +import tc.oc.api.sessions.SessionService; +import tc.oc.commons.bukkit.chat.BukkitAudiences; +import tc.oc.commons.bukkit.chat.ComponentPaginator; +import tc.oc.commons.bukkit.chat.ComponentRenderers; +import tc.oc.commons.bukkit.chat.HeaderComponent; +import tc.oc.commons.bukkit.format.UserFormatter; +import tc.oc.commons.bukkit.nick.IdentityProvider; +import tc.oc.commons.core.chat.Audience; +import tc.oc.commons.core.chat.Component; +import tc.oc.commons.core.commands.CommandFutureCallback; +import tc.oc.commons.core.commands.Commands; +import tc.oc.commons.core.commands.TranslatableCommandException; + +/** + * Commands for querying and possibly manipulating user records + */ +public class UserCommands implements Commands { + + private final MinecraftService minecraftService; + private final SyncExecutor syncExecutor; + private final SessionService sessionService; + private final UserFinder userFinder; + private final IdentityProvider identityProvider; + private final UserFormatter userFormatter; + + @Inject UserCommands(MinecraftService minecraftService, SyncExecutor syncExecutor, SessionService sessionService, UserFinder userFinder, IdentityProvider identityProvider, UserFormatter userFormatter) { + this.minecraftService = minecraftService; + this.syncExecutor = syncExecutor; + this.sessionService = sessionService; + this.userFinder = userFinder; + this.identityProvider = identityProvider; + this.userFormatter = userFormatter; + } + + @Command( + aliases = { "seen", "find" }, + usage = "", + desc = "Shows when a player was last seen", + min = 1, + max = 1 + ) + @CommandPermissions("projectares.seen") + public void find(final CommandContext args, final CommandSender sender) throws CommandException { + syncExecutor.callback( + userFinder.findUser(sender, args, 0), + CommandFutureCallback.onSuccess(sender, args, result -> { + ComponentRenderers.send(sender, userFormatter.formatLastSeen(result)); + }) + ); + } + + @Command( + aliases = { "friends", "fr", "fs" }, + usage = "[page #]", + desc = "Shows what servers your friends are on", + min = 0, + max = 1 + ) + @CommandPermissions("projectares.friends.view") + public void friends(final CommandContext args, final CommandSender sender) throws CommandException { + final PlayerId playerId = Users.playerId(CommandUtils.senderToPlayer(sender)); + final int page = args.getInteger(0, 1); + + syncExecutor.callback( + sessionService.friends(playerId), + CommandFutureCallback.onSuccess(sender, args, result -> { + if(result.documents().isEmpty()) { + throw new TranslatableCommandException("command.friends.none"); + } + + new ComponentPaginator() { + @Override protected BaseComponent title() { + return new TranslatableComponent("command.friends.title"); + } + }.display(sender, userFormatter.formatSessions(result.documents()), page); + }) + ); + } + + @Command( + aliases = { "staff", "mods" }, + desc = "List staff members who are on the network right now", + min = 0, + max = 0 + ) + @CommandPermissions("projectares.showstaff") + public void staff(final CommandContext args, final CommandSender sender) throws CommandException { + + syncExecutor.callback( + sessionService.staff(minecraftService.getLocalServer().network(), identityProvider.revealAll(sender)), + CommandFutureCallback.onSuccess(sender, args, result -> { + final Audience audience = BukkitAudiences.getAudience(sender); + if(result.documents().isEmpty()) { + audience.sendMessage(new TranslatableComponent("command.staff.noStaffOnline")); + return; + } + + audience.sendMessage(new HeaderComponent( + new Component(ChatColor.GRAY) + .extra(new Component(new TranslatableComponent("command.staff.title"), ChatColor.BLUE)) + .extra(new Component(" (")) + .extra(new Component(String.valueOf(result.documents().size()), ChatColor.AQUA)) + .extra(")") + )); + userFormatter.formatSessions(result.documents()).forEach(audience::sendMessage); + }) + ); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/commands/UserFinder.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/commands/UserFinder.java new file mode 100644 index 0000000..1029076 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/commands/UserFinder.java @@ -0,0 +1,226 @@ +package tc.oc.commons.bukkit.commands; + +import javax.annotation.Nullable; +import javax.inject.Inject; +import javax.inject.Singleton; + +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.SettableFuture; +import com.sk89q.minecraft.util.commands.CommandContext; +import com.sk89q.minecraft.util.commands.CommandException; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import tc.oc.api.bukkit.users.BukkitUserStore; +import tc.oc.api.bukkit.users.OnlinePlayers; +import tc.oc.api.docs.User; +import tc.oc.api.exceptions.NotFound; +import tc.oc.api.minecraft.MinecraftService; +import tc.oc.api.users.UserSearchRequest; +import tc.oc.api.users.UserSearchResponse; +import tc.oc.api.users.UserService; +import tc.oc.commons.bukkit.nick.IdentityProvider; +import tc.oc.minecraft.scheduler.MainThreadExecutor; +import tc.oc.commons.bukkit.users.PlayerSearchResponse; +import tc.oc.commons.core.commands.TranslatableCommandException; +import tc.oc.commons.core.util.Orderable; + +@Singleton +public class UserFinder { + + public enum Default implements Orderable { NULL, THROW, SENDER } + public enum Scope implements Orderable { LOCAL, ONLINE, ALL } + + private final MinecraftService minecraftService; + private final UserService userService; + private final BukkitUserStore userStore; + private final IdentityProvider identityProvider; + private final MainThreadExecutor mainThreadExecutor; + private final OnlinePlayers onlinePlayers; + + @Inject UserFinder(MinecraftService minecraftService, UserService userService, BukkitUserStore userStore, IdentityProvider identityProvider, MainThreadExecutor mainThreadExecutor, OnlinePlayers onlinePlayers) { + this.minecraftService = minecraftService; + this.userService = userService; + this.userStore = userStore; + this.identityProvider = identityProvider; + this.mainThreadExecutor = mainThreadExecutor; + this.onlinePlayers = onlinePlayers; + } + + public Player senderToPlayer(CommandSender sender) throws CommandException { + if(sender instanceof Player) { + return (Player) sender; + } else { + throw new TranslatableCommandException("command.onlyPlayers"); + } + } + + public @Nullable User getLocalUser(CommandSender sender) { + if(sender instanceof Player) { + return userStore.getUser((Player) sender); + } + return null; + } + + public @Nullable Player getLocalPlayer(CommandSender sender, String username) { + return sender.getServer().getPlayerExact(username, sender); + } + + public UserSearchResponse localUserResponse(CommandSender viewer, Player player) { + return new UserSearchResponse( + userStore.getUser(player), + true, + identityProvider.currentIdentity(player).isDisguised(viewer), + userStore.getSession(player), + minecraftService.getLocalServer() + ); + } + + public PlayerSearchResponse localPlayerResponse(CommandSender viewer, Player player) { + return new PlayerSearchResponse( + localUserResponse(viewer, player), + player + ); + } + + public ListenableFuture findUser(final CommandSender sender, @Nullable String name, Scope scope, Default def) { + try { + if(name != null) { + final Player player = getLocalPlayer(sender, name); + if(player != null) { + return Futures.immediateFuture(localUserResponse(sender, player)); + } + + if(scope.noGreaterThan(Scope.LOCAL)) { + throw new TranslatableCommandException("command.playerNotFound"); + } + + final SettableFuture result = SettableFuture.create(); + Futures.addCallback( + userService.search(new UserSearchRequest(name, getLocalUser(sender))), + new FutureCallback() { + @Override + public void onSuccess(@Nullable UserSearchResponse response) { + if(!response.online && scope.noGreaterThan(Scope.ONLINE)) { + result.setException(new TranslatableCommandException("command.playerNotFound")); + } else { + result.set(response); + } + } + + @Override + public void onFailure(Throwable e) { + if(e instanceof NotFound) { + result.setException(new TranslatableCommandException("command.playerNotFound")); + } else { + result.setException(e); + } + } + } + ); + + return result; + } else { + switch(def) { + case NULL: + return Futures.immediateFuture(null); + + case SENDER: + return Futures.immediateFuture(localUserResponse(sender, senderToPlayer(sender))); + + default: + throw new TranslatableCommandException("command.specifyPlayer"); + } + } + } catch(CommandException e) { + return Futures.immediateFailedFuture(e); + } + } + + public ListenableFuture findPlayer(CommandSender sender, @Nullable String name, Scope scope, Default def) { + try { + final Player player = getLocalPlayer(sender, name); + if(player != null) { + return Futures.immediateFuture(localPlayerResponse(sender, player)); + } + + if(scope.noGreaterThan(Scope.LOCAL)) { + throw new TranslatableCommandException("command.playerNotFound"); + } + + final SettableFuture playerResult = SettableFuture.create(); + mainThreadExecutor.callback( + findUser(sender, name, scope, def), + new FutureCallback() { + @Override + public void onSuccess(@Nullable UserSearchResponse userResult) { + playerResult.set(new PlayerSearchResponse(userResult, onlinePlayers.find(userResult.user))); + } + + @Override + public void onFailure(Throwable t) { + playerResult.setException(t); + } + } + ); + + return playerResult; + } catch(CommandException e) { + return Futures.immediateFailedFuture(e); + } + } + + + // findUser overloads + + public ListenableFuture findUser(final CommandSender sender, CommandContext args, int index, Scope scope, Default def) { + return findUser(sender, args.getString(index, null), scope, def); + } + + public ListenableFuture findUser(final CommandSender sender, CommandContext args, int index, Default def) { + return findUser(sender, args, index, Scope.ALL, def); + } + + public ListenableFuture findUser(final CommandSender sender, CommandContext args, int index, Scope scope) { + return findUser(sender, args.getString(index, null), scope, Default.THROW); + } + + public ListenableFuture findUser(final CommandSender sender, CommandContext args, int index) { + return findUser(sender, args, index, Scope.ALL, Default.THROW); + } + + + // findPlayer overloads + + public ListenableFuture findPlayer(CommandSender sender, CommandContext args, int index, Scope scope, Default def) { + return findPlayer(sender, args.getString(index, null), scope, def); + } + + public ListenableFuture findPlayer(CommandSender sender, CommandContext args, int index, Scope scope) { + return findPlayer(sender, args, index, scope, Default.THROW); + } + + public ListenableFuture findPlayer(CommandSender sender, CommandContext args, int index, Default def) { + return findPlayer(sender, args, index, Scope.ALL, def); + } + + public ListenableFuture findPlayer(CommandSender sender, CommandContext args, int index) { + return findPlayer(sender, args, index, Scope.ALL, Default.THROW); + } + + + // Special cases + + public ListenableFuture findLocalPlayer(CommandSender sender, CommandContext args, int index, Default def) { + return findPlayer(sender, args, index, Scope.LOCAL, def); + } + + public ListenableFuture findLocalPlayer(CommandSender sender, CommandContext args, int index) { + return findLocalPlayer(sender, args, index, Default.THROW); + } + + public ListenableFuture findLocalPlayerOrSender(CommandSender sender, CommandContext args, int index) { + return findLocalPlayer(sender, args, index, Default.SENDER); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/config/ExternalConfiguration.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/config/ExternalConfiguration.java new file mode 100644 index 0000000..615ee11 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/config/ExternalConfiguration.java @@ -0,0 +1,83 @@ +package tc.oc.commons.bukkit.config; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.logging.Level; +import javax.annotation.Nullable; +import javax.inject.Inject; +import javax.inject.Named; + +import org.bukkit.configuration.Configuration; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; +import tc.oc.commons.bukkit.logging.MapdevLogger; +import tc.oc.minecraft.scheduler.MainThreadExecutor; +import tc.oc.file.PathWatcher; +import tc.oc.file.PathWatcherService; +import tc.oc.minecraft.api.configuration.InvalidConfigurationException; + +/** + * Base class for a configuration section that is loaded from the external + * configuration folder in the maps repo if present, otherwise from the root + * of this plugin's configuration. + * + * The external file is rechecked on some interval (1 minute by default). + */ +public abstract class ExternalConfiguration { + + @Inject private MapdevLogger mapdevLogger; + @Inject private Configuration pluginConfig; + + private ConfigurationSection config; + + @Inject void init(@Named("configuration") Path configPath, PathWatcherService watcherService, MainThreadExecutor executor) throws IOException { + final Path path = configPath.resolve(fileName() + ".yml"); + reload(path); + watcherService.watch(path, executor, new PathWatcher() { + @Override + public void fileCreated(Path path) { + reload(path); + } + + @Override + public void fileModified(Path path) { + reload(path); + } + + @Override + public void fileDeleted(Path path) { + reload(path); + } + }); + } + + private void reload(Path file) { + try { + final ConfigurationSection before = config; + final ConfigurationSection after; + if(Files.isRegularFile(file)) { + after = new YamlConfiguration(); + ((YamlConfiguration) after).load(file.toFile()); + } else { + after = pluginConfig.getSection(configName()); + } + configChanged(before, after); + config = after; // Don't change the config if the callback throws + } catch (IOException | InvalidConfigurationException e) { + mapdevLogger.log(Level.SEVERE, "Error loading " + fileName() + ".yml: " + e.getMessage(), e); + } + } + + protected abstract String configName(); + + protected String fileName() { + return configName(); + } + + protected void configChanged(@Nullable ConfigurationSection before, @Nullable ConfigurationSection after) throws InvalidConfigurationException {} + + protected ConfigurationSection config() { + return config; + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/debug/LeakListener.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/debug/LeakListener.java new file mode 100644 index 0000000..b918c1a --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/debug/LeakListener.java @@ -0,0 +1,50 @@ +package tc.oc.commons.bukkit.debug; + +import javax.annotation.Nullable; +import javax.inject.Inject; + +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.event.world.WorldUnloadEvent; +import java.time.Duration; +import tc.oc.api.bukkit.users.BukkitUserStore; +import tc.oc.api.docs.virtual.Model; +import tc.oc.api.model.ModelDispatcher; +import tc.oc.api.model.ModelListener; +import tc.oc.commons.core.plugin.PluginFacet; +import tc.oc.debug.LeakDetector; + +public class LeakListener implements PluginFacet, Listener, ModelListener { + + private static final Duration DEADLINE = Duration.ofSeconds(10); + + private final LeakDetector leakDetector; + private final BukkitUserStore userStore; + + @Inject LeakListener(LeakDetector leakDetector, BukkitUserStore userStore, ModelDispatcher modelDispatcher) { + this.leakDetector = leakDetector; + this.userStore = userStore; + modelDispatcher.subscribe(this); + } + + @HandleModel + public void modelUpdated(@Nullable Model before, @Nullable Model after, Model latest) { + if(before != null && before != after) { + leakDetector.expectRelease(before, DEADLINE, true); + } + } + + @EventHandler(priority = EventPriority.LOWEST) + public void onLogout(PlayerQuitEvent event) { + leakDetector.expectRelease(event.getPlayer(), DEADLINE, true); + leakDetector.expectRelease(userStore.tryUser(event.getPlayer()), DEADLINE, true); + leakDetector.expectRelease(userStore.getSession(event.getPlayer()), DEADLINE, true); + } + + @EventHandler(priority = EventPriority.LOWEST) + public void onWorldChange(WorldUnloadEvent event) { + leakDetector.expectRelease(event.getWorld(), DEADLINE, true); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/event/AsyncUserLoginEvent.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/event/AsyncUserLoginEvent.java new file mode 100644 index 0000000..5770ff2 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/event/AsyncUserLoginEvent.java @@ -0,0 +1,47 @@ +package tc.oc.commons.bukkit.event; + +import org.bukkit.event.Event; +import org.bukkit.event.HandlerList; +import tc.oc.api.docs.User; +import tc.oc.api.users.LoginResponse; + +/** + * Fired from a background thread during login, after we have retrieved + * the player's data and decided they are allowed to connect. + * + * There is NO guarantee that a synchronous login will follow for this + * player. If the connection drops, or something else fails, before they + * make it to the synchronous part of the login, then this event is the + * last you will ever hear from them. For that reason, you need to be + * careful about allocating any per-player resources from this event, + * being sure to clean up any that leak from failed logins. + */ +public class AsyncUserLoginEvent extends Event implements UserEvent { + + private final LoginResponse response; + + public AsyncUserLoginEvent(LoginResponse response) { + super(true); + this.response = response; + } + + public LoginResponse response() { + return response; + } + + @Override + public User getUser() { + return response.user(); + } + + private static final HandlerList handlers = new HandlerList(); + + @Override + public HandlerList getHandlers() { + return handlers; + } + + public static HandlerList getHandlerList() { + return handlers; + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/event/ObserverKitApplyEvent.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/event/ObserverKitApplyEvent.java new file mode 100644 index 0000000..43511c1 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/event/ObserverKitApplyEvent.java @@ -0,0 +1,24 @@ +package tc.oc.commons.bukkit.event; + +import org.bukkit.entity.Player; +import org.bukkit.event.EntityAction; +import org.bukkit.event.HandlerList; +import org.bukkit.event.player.PlayerEvent; + +/** + * Called when a non-participating player spawns (observers in PGM, everybody in the Lobby) + */ +public class ObserverKitApplyEvent extends PlayerEvent implements EntityAction { + public ObserverKitApplyEvent(Player player) { + super(player); + } + + @Override + public Player getActor() { + return getPlayer(); + } + + private static final HandlerList handlers = new HandlerList(); + @Override public HandlerList getHandlers() { return handlers; } + public static HandlerList getHandlerList() { return handlers; } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/event/PlayerServerChangeEvent.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/event/PlayerServerChangeEvent.java new file mode 100644 index 0000000..618784e --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/event/PlayerServerChangeEvent.java @@ -0,0 +1,41 @@ +package tc.oc.commons.bukkit.event; + +import javax.annotation.Nullable; + +import net.md_5.bungee.api.chat.BaseComponent; +import org.bukkit.entity.Player; +import org.bukkit.event.HandlerList; +import org.bukkit.event.PlayerAction; + +import static com.google.common.base.Preconditions.checkNotNull; + +public class PlayerServerChangeEvent extends ExtendedCancellable implements PlayerAction { + + private final Player player; + private final String datacenter; + private final @Nullable String bungeeName; + + public PlayerServerChangeEvent(Player player, String datacenter, @Nullable String bungeeName, BaseComponent cancelMessage) { + super(cancelMessage); + this.datacenter = datacenter; + this.player = checkNotNull(player); + this.bungeeName = bungeeName; + } + + @Override + public Player getActor() { + return player; + } + + public Player getPlayer() { + return player; + } + + public @Nullable String getBungeeName() { + return bungeeName; + } + + @Override public HandlerList getHandlers() { return handlers; } + private static final HandlerList handlers = new HandlerList(); + public static HandlerList getHandlerList() { return handlers; } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/event/UserEvent.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/event/UserEvent.java new file mode 100644 index 0000000..191fee8 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/event/UserEvent.java @@ -0,0 +1,10 @@ +package tc.oc.commons.bukkit.event; + +import tc.oc.api.docs.User; + +/** + * Represents an event involving a {@link User}. + */ +public interface UserEvent { + User getUser(); +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/event/UserLoginEvent.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/event/UserLoginEvent.java new file mode 100644 index 0000000..222d75d --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/event/UserLoginEvent.java @@ -0,0 +1,102 @@ +package tc.oc.commons.bukkit.event; + +import javax.annotation.Nullable; + +import net.md_5.bungee.api.chat.BaseComponent; +import org.bukkit.entity.Player; +import org.bukkit.event.Event; +import org.bukkit.event.HandlerList; +import org.bukkit.event.player.PlayerLoginEvent; +import tc.oc.api.docs.Session; +import tc.oc.api.docs.User; +import tc.oc.api.users.LoginResponse; + +import static com.google.common.base.Preconditions.checkNotNull; + +/** + * Fired from within {@link PlayerLoginEvent}, and inludes our {@link User} document. + * It also fires after various login info has been loaded e.g. PlayerId, permissions, + * settings, friends. + * + * It's a good idea to use this event instead of {@link PlayerLoginEvent} whenever + * possible, to maximize the amount of valid state loaded for the player. + * + * Choose the priority carefully when registering a handler for this event: + * TODO: Less fragile way to organize this + * + * LOWEST Things that cancel the login + * LOW Silent initialization of player state + * NORMAL Welcome message + * HIGH Alerts + * HIGHEST Private messages + * MONITOR Nickname reminder + * + * NOTE: No handler should un-cancel the login once it has been cancelled, + * because some handlers may have already ignored the event. + * + */ +public class UserLoginEvent extends Event implements UserEvent { + + private final Player player; + private final LoginResponse response; + + private PlayerLoginEvent.Result result; + private @Nullable BaseComponent kickMessage; + + public UserLoginEvent(Player player, LoginResponse response, PlayerLoginEvent.Result result, @Nullable BaseComponent kickMessage) { + this.response = checkNotNull(response); + this.player = checkNotNull(player); + this.result = checkNotNull(result); + this.kickMessage = kickMessage; + } + + public Player getPlayer() { + return player; + } + + public LoginResponse response() { + return response; + } + + @Override + public User getUser() { + return response.user(); + } + + public @Nullable Session getSession() { + return response.session(); + } + + public PlayerLoginEvent.Result getResult() { + return result; + } + + public @Nullable BaseComponent getKickMessage() { + return kickMessage; + } + + public void setKickMessage(@Nullable BaseComponent kickMessage) { + this.kickMessage = kickMessage; + } + + public void allow() { + this.result = PlayerLoginEvent.Result.ALLOWED; + this.kickMessage = null; + } + + public void disallow(PlayerLoginEvent.Result result, BaseComponent message) { + this.result = result; + this.kickMessage = message; + } + + private static final HandlerList handlers = new HandlerList(); + + @Override + public HandlerList getHandlers() { + return handlers; + } + + public static HandlerList getHandlerList() { + return handlers; + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/event/WhitelistStateChangeEvent.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/event/WhitelistStateChangeEvent.java new file mode 100644 index 0000000..40e15bc --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/event/WhitelistStateChangeEvent.java @@ -0,0 +1,29 @@ +package tc.oc.commons.bukkit.event; + +import org.bukkit.event.Event; +import org.bukkit.event.HandlerList; + +public class WhitelistStateChangeEvent extends Event { + + private final boolean enabled; + + public WhitelistStateChangeEvent(boolean enabled) { + this.enabled = enabled; + } + + public boolean isEnabled() { + return enabled; + } + + private static final HandlerList handlers = new HandlerList(); + + @Override + public HandlerList getHandlers() { + return handlers; + } + + public static HandlerList getHandlerList() { + return handlers; + } + +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/format/GameFormatter.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/format/GameFormatter.java new file mode 100644 index 0000000..75d0a1a --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/format/GameFormatter.java @@ -0,0 +1,235 @@ +package tc.oc.commons.bukkit.format; + +import java.util.Collection; +import java.util.Optional; +import javax.inject.Inject; + +import net.md_5.bungee.api.ChatColor; +import net.md_5.bungee.api.chat.BaseComponent; +import net.md_5.bungee.api.chat.ClickEvent; +import net.md_5.bungee.api.chat.HoverEvent; +import net.md_5.bungee.api.chat.TranslatableComponent; +import tc.oc.api.docs.Arena; +import tc.oc.api.docs.Game; +import tc.oc.api.docs.Server; +import tc.oc.api.servers.ServerStore; +import tc.oc.commons.bukkit.chat.WarningComponent; +import tc.oc.commons.bukkit.localization.Translations; +import tc.oc.commons.core.chat.Audience; +import tc.oc.commons.core.chat.Component; +import tc.oc.commons.core.chat.Components; + +public class GameFormatter { + + public static final ChatColor MESSAGE_COLOR = ChatColor.YELLOW; + public static final ChatColor NAME_COLOR = ChatColor.AQUA; + + private @Inject Translations translations; + private @Inject ServerStore servers; + private @Inject Server localServer; + private @Inject MiscFormatter miscFormatter; + + public static class Dark extends GameFormatter { + @Override + protected ChatColor countColor() { + return ChatColor.DARK_AQUA; + } + + @Override + protected ChatColor slashColor() { + return ChatColor.GRAY; + } + + @Override + protected ChatColor limitColor() { + return ChatColor.DARK_GRAY; + } + } + + protected ChatColor countColor() { + return ChatColor.WHITE; + } + + protected ChatColor slashColor() { + return ChatColor.DARK_GRAY; + } + + protected ChatColor limitColor() { + return ChatColor.GRAY; + } + + public BaseComponent play(Game game) { + return new TranslatableComponent("game.play", name(game, false)); + } + + public BaseComponent replay(Game game) { + return new TranslatableComponent("game.replay", name(game, false)); + } + + public BaseComponent replayMaybe(Game game) { + return new Component( + new TranslatableComponent( + "game.replayMaybe", + new Component("/replay", NAME_COLOR) + .extra(" ") + .extra(name(game, false)) + ), ChatColor.GREEN + ); + } + + public BaseComponent leave(Game game) { + return new TranslatableComponent("game.leave", name(game, false)); + } + + public BaseComponent name(Game game) { + return name(game, true); + } + + public BaseComponent name(Game game, boolean clickable) { + final Component c = new Component(game.name(), NAME_COLOR); + if(!clickable) return c; + + return new Component(ChatColor.WHITE) + .extra("[") + .extra(c.clickEvent(ClickEvent.Action.RUN_COMMAND, "/play " + game.name()) + .hoverEvent(HoverEvent.Action.SHOW_TEXT, play(game))) + .extra("]"); + } + + public static String descriptionKey(Game game) { + return "game.description." + game._id(); + } + + public BaseComponent description(Game game) { + final String key = descriptionKey(game); + return translations.hasKey(key) ? new Component(new TranslatableComponent(key), ChatColor.DARK_AQUA) + : Components.blank(); + } + + public BaseComponent joining(Game game) { + return new Component( + new TranslatableComponent("game.joining", name(game)), + MESSAGE_COLOR + ); + } + + public BaseComponent rejoining(Game game) { + return new Component( + new TranslatableComponent("game.rejoining", name(game)), + MESSAGE_COLOR + ); + } + + public BaseComponent cannotJoin(Game game) { + return new WarningComponent("game.cannotJoin", name(game)); + } + + public BaseComponent queued(Game game, int playersNeeded) { + return new Component( + new TranslatableComponent("game.waitingForPlayers", new Component(playersNeeded, ChatColor.AQUA), name(game, false)), + MESSAGE_COLOR + ); + } + + public BaseComponent left(Game game) { + return new Component( + new TranslatableComponent("game.left", name(game)), + MESSAGE_COLOR + ); + } + + public BaseComponent notPlaying() { + return new WarningComponent("game.notPlaying"); + } + + public BaseComponent alreadyPlaying(Game game) { + return new WarningComponent("game.alreadyPlaying", name(game)); + } + + public Optional minimumPlayers(Arena arena) { + return arena.next_server_id() == null ? Optional.empty() + : Optional.of(servers.byId(arena.next_server_id()).min_players()); + } + + public int countObservers(Arena arena) { + return servers.byArena(arena) + .stream() + .mapToInt(Server::num_observing) + .sum(); + } + + public BaseComponent countAndMax(int count, int max) { + return max < 0 ? new Component(count, countColor()) + : new Component(new Component(count, countColor()), + new Component("/", slashColor()), + new Component(max, limitColor())); + } + + public BaseComponent onlineCount(int count) { + return new Component(new TranslatableComponent("game.numOnline", new Component(count, ChatColor.WHITE)), + ChatColor.BLUE); + } + + public BaseComponent onlineCount(Server server) { + return onlineCount(server.num_online()); + } + + public BaseComponent onlineCount(Arena arena) { + return onlineCount(arena.num_playing() + arena.num_queued()); + } + + public BaseComponent playingCount(int count, int max) { + return new Component(new TranslatableComponent("game.numPlaying", countAndMax(count, max)), + ChatColor.DARK_GREEN); + } + + public BaseComponent playingCount(Arena arena) { + return playingCount(arena.num_playing(), -1); + } + + public BaseComponent playingCount(Server server) { + return playingCount(server.num_participating(), server.max_players()); + } + + public BaseComponent watchingCount(int count) { + return new Component(new TranslatableComponent("game.numWatching", new Component(count, ChatColor.WHITE)), + ChatColor.BLUE); + } + + public BaseComponent watchingCount(Server server) { + return watchingCount(server.num_observing()); + } + + public BaseComponent watchingCount(Arena arena) { + return watchingCount(countObservers(arena)); + } + + public BaseComponent waitingCount(Arena arena) { + return new Component(new TranslatableComponent("game.numQueued", countAndMax(arena.num_queued(), minimumPlayers(arena).orElse(-1))), + ChatColor.DARK_PURPLE); + } + + public void sendList(Audience audience, Collection games) { + if(games.isEmpty()) return; + + audience.sendMessage( + new Component( + new TranslatableComponent( + "game.choose", + new Component("/play ", ChatColor.GOLD), + new Component("/watch ", ChatColor.GOLD) + ), + MESSAGE_COLOR + ) + ); + + for(Game game : games) { + audience.sendMessage( + new Component(" ") + .extra(name(game)) + .extra(" ") + .extra(description(game)) + ); + } + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/format/MiscFormatter.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/format/MiscFormatter.java new file mode 100644 index 0000000..8f9bf61 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/format/MiscFormatter.java @@ -0,0 +1,32 @@ +package tc.oc.commons.bukkit.format; + +import net.md_5.bungee.api.ChatColor; +import net.md_5.bungee.api.chat.BaseComponent; +import net.md_5.bungee.api.chat.ClickEvent; +import net.md_5.bungee.api.chat.TranslatableComponent; +import tc.oc.commons.core.chat.Component; + +public class MiscFormatter { + + public BaseComponent abled(boolean enabled) { + return new Component( + new TranslatableComponent(enabled ? "misc.enabled" : "misc.disabled"), + enabled ? ChatColor.GREEN : ChatColor.RED + ); + } + + public BaseComponent typePrefix(String text) { + return new Component(ChatColor.WHITE) + .extra("[") + .extra(new Component(text, ChatColor.GOLD)) + .extra("] "); + } + + public BaseComponent clickHere(ClickEvent.Action action, String value) { + return new Component( + new TranslatableComponent("misc.clickHere"), + ChatColor.AQUA, + ChatColor.BOLD + ).clickEvent(action, value); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/format/ServerFormatter.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/format/ServerFormatter.java new file mode 100644 index 0000000..3ff7f5a --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/format/ServerFormatter.java @@ -0,0 +1,304 @@ +package tc.oc.commons.bukkit.format; + +import java.util.Comparator; +import java.util.Optional; +import javax.annotation.Nullable; +import javax.inject.Inject; + +import net.md_5.bungee.api.ChatColor; +import net.md_5.bungee.api.chat.BaseComponent; +import net.md_5.bungee.api.chat.ClickEvent; +import net.md_5.bungee.api.chat.HoverEvent; +import net.md_5.bungee.api.chat.TranslatableComponent; +import java.time.Duration; +import java.time.Instant; +import tc.oc.api.docs.virtual.MatchDoc; +import tc.oc.api.docs.virtual.ServerDoc; +import tc.oc.api.minecraft.MinecraftService; +import tc.oc.commons.core.chat.Component; +import tc.oc.commons.core.chat.Components; +import tc.oc.commons.core.formatting.PeriodFormats; + +public class ServerFormatter { + + public static Comparator proximityOrder(final ServerDoc.Identity server) { + return (a, b) -> { + int cmp = Boolean.compare(server.equals(b), server.equals(a)); + if(cmp != 0) return cmp; + + return Boolean.compare(server.datacenter().equals(b.datacenter()), server.datacenter().equals(a.datacenter())); + }; + } + + public interface Colors { + ChatColor online(); + ChatColor restarting(); + ChatColor offline(); + + ChatColor label(); + + ChatColor background(); + ChatColor maxPlayers(); + ChatColor players(); + ChatColor participants(); + ChatColor observers(); + + ChatColor matchIdle(); + ChatColor matchStarting(); + ChatColor matchRunning(); + ChatColor matchFinished(); + + ChatColor mapName(); + } + + // Picker and chat + public static final Colors lightColors = new Colors() { + @Override public ChatColor online() { return ChatColor.YELLOW; } + @Override public ChatColor restarting() { return ChatColor.BLUE; } + @Override public ChatColor offline() { return ChatColor.DARK_GRAY; } + + @Override public ChatColor label() { return ChatColor.BLUE; } + + @Override public ChatColor background() { return ChatColor.DARK_GRAY; } + @Override public ChatColor maxPlayers() { return ChatColor.WHITE; } + @Override public ChatColor players() { return ChatColor.AQUA; } + @Override public ChatColor participants() { return ChatColor.GREEN; } + @Override public ChatColor observers() { return ChatColor.AQUA; } + + @Override public ChatColor matchIdle() { return ChatColor.WHITE; } + @Override public ChatColor matchStarting() { return ChatColor.GREEN; } + @Override public ChatColor matchRunning() { return ChatColor.BLUE; } + @Override public ChatColor matchFinished() { return ChatColor.RED; } + + @Override public ChatColor mapName() { return ChatColor.AQUA; } + }; + + // Signs + public static final Colors darkColors = new Colors() { + @Override public ChatColor online() { return ChatColor.BLACK; } + @Override public ChatColor restarting() { return ChatColor.DARK_GRAY; } + @Override public ChatColor offline() { return ChatColor.GRAY; } + + @Override public ChatColor label() { return ChatColor.DARK_BLUE; } + + @Override public ChatColor background() { return ChatColor.GRAY; } + @Override public ChatColor maxPlayers() { return ChatColor.DARK_GRAY; } + @Override public ChatColor players() { return ChatColor.DARK_AQUA; } + @Override public ChatColor participants() { return ChatColor.DARK_AQUA; } + @Override public ChatColor observers() { return ChatColor.DARK_AQUA; } + + @Override public ChatColor matchIdle() { return ChatColor.DARK_GRAY; } + @Override public ChatColor matchStarting() { return ChatColor.BLUE; } + @Override public ChatColor matchRunning() { return ChatColor.DARK_GREEN; } + @Override public ChatColor matchFinished() { return ChatColor.DARK_RED; } + + @Override public ChatColor mapName() { return ChatColor.DARK_GREEN; } + }; + + @Inject static private MinecraftService minecraftService; + + // TODO: figure out an injection-friendly way to do this + @Deprecated public static final ServerFormatter light = new ServerFormatter(lightColors); + @Deprecated public static final ServerFormatter dark = new ServerFormatter(darkColors); + + private final Colors colors; + public Colors colors() { return colors; } + + private ServerFormatter(Colors colors) { + this.colors = colors; + } + + public boolean isRestarting(ServerDoc.Listing server) { + return server.running() && ( + !server.online() || ( + server.restart_queued_at() != null && ( + server.current_match() == null || server.current_match().end() != null + ) + ) + ); + } + + public ChatColor statusColor(ServerDoc.Listing server) { + if(isRestarting(server)) { + return colors.restarting(); + } else if(server.online()) { + return colors.online(); + } else { + return colors.offline(); + } + } + + public BaseComponent name(ServerDoc.Listing server) { + return new Component(server.name(), server.online() ? colors.online() : colors.offline(), ChatColor.BOLD); + } + + public Optional description(ServerDoc.Listing server) { + return server.description() == null ? Optional.empty() + : Optional.of(new Component(new TranslatableComponent(server.description()), ChatColor.DARK_AQUA)); + } + + public BaseComponent onlineStatus(ServerDoc.Listing server) { + final String key; + if(isRestarting(server)) { + key = "servers.restarting"; + } else if(server.online()) { + key = "servers.online"; + } else { + key = "servers.offline"; + } + return new Component(new TranslatableComponent(key), statusColor(server)); + } + + public BaseComponent nameWithDatacenter(ServerDoc.Identity s) { + return nameWithDatacenter(s.datacenter(), s.bungee_name(), s.name(), s.role() == ServerDoc.Role.LOBBY); + } + + public BaseComponent nameWithDatacenter(@Nullable String datacenter, @Nullable String bungee, @Nullable String name, boolean lobby) { + return nameWithDatacenter(minecraftService.getLocalServer(), datacenter, bungee, name, lobby); + } + + public BaseComponent nameWithDatacenter(ServerDoc.Identity local, @Nullable String datacenter, @Nullable String bungee, @Nullable String name, boolean lobby) { + Component c = new Component(ChatColor.WHITE); + + if(datacenter == null) { + datacenter = local.datacenter(); + } + + if(!datacenter.equals(local.datacenter())) { + c.extra("[").extra(new Component(datacenter, ChatColor.GOLD)).extra("] "); + } + + Component nameComponent = new Component(ChatColor.GOLD); + if(name != null) { + nameComponent.text(name); + } else { + nameComponent.extra(new TranslatableComponent("servers.lobby")); + } + + nameComponent.hoverEvent(HoverEvent.Action.SHOW_TEXT, new TranslatableComponent("tip.connectTo", nameComponent.duplicate())); + + if(lobby) { + nameComponent.clickEvent(makeLobbyClickEvent()); + } else { + nameComponent.clickEvent(makeServerClickEvent(bungee)); + } + + c.extra("[").extra(nameComponent).extra("]"); + + return c; + } + + /** + * [server] [player counts] [match time] + */ + public BaseComponent compactHeading(ServerDoc.Listing server) { + if(server.online()) { + Component c = new Component() + .extra(new Component(server.name(), ChatColor.WHITE, ChatColor.BOLD)) + .extra(" ") + .extra(playerCounts(server, true)); + + if(server.current_match() != null) { + c.extra(" ").extra(matchTime(server.current_match())); + } + + return c; + } else { + return new Component(ChatColor.DARK_GRAY) + .extra(new Component(server.name(), ChatColor.BOLD)) + .extra(" (") + .extra(new TranslatableComponent("servers.offline")) + .extra(")"); + } + } + + public BaseComponent playerCounts(ServerDoc.Listing server, boolean observers) { + Component c = new Component(colors.background()); + + if(server.role() == ServerDoc.Role.PGM) { + c.extra(new Component(String.valueOf(observers ? server.num_participating() : server.num_online()), colors.participants())); + c.extra("/"); + c.extra(new Component(String.valueOf(server.max_players()), colors.maxPlayers())); + if(observers) { + c.extra(" ("); + c.extra(new Component(String.valueOf(server.num_observing()), colors.observers())); + c.extra(")"); + } + } else { + c.extra(new Component(String.valueOf(server.num_online()), colors.players())); + } + + return c; + } + + public ChatColor matchStatusColor(ServerDoc.Status server) { + final MatchDoc match = server.current_match(); + + if(match != null && server.num_online() > 0) { + if(match.end() != null) { + return colors.matchFinished(); + } else if(match.start() != null) { + return colors.matchRunning(); + } else { + return colors.matchStarting(); + } + } else { + return colors.matchIdle(); + } + } + + public BaseComponent matchTime(MatchDoc doc) { + Duration time; + ChatColor color; + if(doc.start() == null) { + time = Duration.ZERO; + color = ChatColor.GOLD; + } else if(doc.end() == null) { + time = Duration.between(doc.start(), Instant.now()); + color = ChatColor.GREEN; + } else { + time = Duration.between(doc.start(), doc.end()); + color = ChatColor.GOLD; + } + return new Component(PeriodFormats.formatColons(time), color); + } + + public BaseComponent currentMap(ServerDoc.Status server) { + final MatchDoc match = server.current_match(); + if(match == null || match.map() == null) { + return Components.blank(); + } + + return new Component( + new TranslatableComponent( + "servers.currentMap", + new Component(match.map().name(), colors.mapName()) + ), + colors.label() + ); + } + + public BaseComponent nextMap(ServerDoc.Status server) { + if(server.next_map() == null) return Components.blank(); + + return new Component( + new TranslatableComponent( + "servers.nextMap", + new Component(server.next_map().name(), colors.mapName()) + ), + colors.label() + ); + } + + public ClickEvent makeLobbyClickEvent() { + return new ClickEvent(ClickEvent.Action.RUN_COMMAND, "/hub"); + } + + public ClickEvent makeServerClickEvent(ServerDoc.Identity server) { + return makeServerClickEvent(server.bungee_name()); + } + + public ClickEvent makeServerClickEvent(String bungee) { + return new ClickEvent(ClickEvent.Action.RUN_COMMAND, "/server -b " + bungee); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/format/UserFormatter.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/format/UserFormatter.java new file mode 100644 index 0000000..d448cfe --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/format/UserFormatter.java @@ -0,0 +1,128 @@ +package tc.oc.commons.bukkit.format; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import javax.annotation.Nullable; +import javax.inject.Inject; + +import com.google.common.collect.HashMultimap; +import com.google.common.collect.SetMultimap; +import net.md_5.bungee.api.ChatColor; +import net.md_5.bungee.api.chat.BaseComponent; +import net.md_5.bungee.api.chat.TranslatableComponent; +import java.time.Duration; +import java.time.Instant; +import tc.oc.api.docs.Server; +import tc.oc.api.docs.Session; +import tc.oc.api.docs.virtual.ServerDoc; +import tc.oc.api.minecraft.MinecraftService; +import tc.oc.api.servers.ServerStore; +import tc.oc.api.users.UserSearchResponse; +import tc.oc.commons.bukkit.chat.NameStyle; +import tc.oc.commons.bukkit.chat.PlayerComponent; +import tc.oc.commons.bukkit.nick.Identity; +import tc.oc.commons.bukkit.nick.IdentityProvider; +import tc.oc.commons.core.chat.Component; +import tc.oc.commons.core.chat.Components; +import tc.oc.commons.core.formatting.PeriodFormats; + +public class UserFormatter { + + private static final Component ONLINE = new Component(new TranslatableComponent("command.lastSeen.online"), ChatColor.GREEN); + + private final IdentityProvider identityProvider; + private final MinecraftService minecraftService; + private final ServerStore serverStore; + + @Inject UserFormatter(IdentityProvider identityProvider, MinecraftService minecraftService, ServerStore serverStore) { + this.identityProvider = identityProvider; + this.minecraftService = minecraftService; + this.serverStore = serverStore; + } + + public BaseComponent formatLastSeen(UserSearchResponse search) { + return formatLastSeen(search.last_session, identityProvider.createIdentity(search)); + } + + public BaseComponent formatLastSeen(Session session) { + return formatLastSeen(session, identityProvider.createIdentity(session)); + } + + private BaseComponent formatLastSeen(@Nullable Session session, Identity identity) { + final PlayerComponent playerComponent = new PlayerComponent(identity, NameStyle.VERBOSE); + + if(session == null) { + return new TranslatableComponent("command.lastSeen.unknown", playerComponent); + } + + final Server localServer = minecraftService.getLocalServer(); + final BaseComponent serverName; + if(session.server_id() == null || localServer._id().equals(session.server_id())) { + serverName = null; + } else { + Server server = serverStore.byId(session.server_id()); + if(server != null) { + serverName = ServerFormatter.light.nameWithDatacenter(server); + } else { + serverName = new TranslatableComponent("misc.unknown"); + } + } + + if(session.end() == null) { + if(serverName != null) { + return new TranslatableComponent("command.lastSeen.online.server", playerComponent, ONLINE, serverName); + } else { + return new TranslatableComponent("command.lastSeen.online.noServer", playerComponent, ONLINE); + } + } else { + final Component when = new Component(PeriodFormats.briefNaturalApproximate(Duration.between(session.end(), Instant.now())), ChatColor.AQUA); + + if(serverName != null) { + return new TranslatableComponent("command.lastSeen.offline.server", playerComponent, when, serverName); + } else { + return new TranslatableComponent("command.lastSeen.offline.noServer", playerComponent, when); + } + } + } + + public List formatSessions(Collection sessions) { + return formatSessions(sessions, minecraftService.getLocalServer()); + } + + public List formatSessions(Collection sessions, ServerDoc.Identity localServer) { + List lines = new ArrayList<>(); + + SetMultimap namesByServer = HashMultimap.create(); + + for(Session session : sessions) { + if(session.end() == null) { + final Server server = serverStore.byId(session.server_id()); + if(server != null) { + namesByServer.put(server, new PlayerComponent(identityProvider.createIdentity(session), NameStyle.FANCY)); + } + } + } + + List sortedServers = new ArrayList<>(namesByServer.keySet()); + Collections.sort(sortedServers, ServerFormatter.proximityOrder(localServer)); + + for(Server server : sortedServers) { + lines.add( + new Component() + .extra(ServerFormatter.light.nameWithDatacenter(server)) + .extra(" ") + .extra(Components.join(new Component(" "), namesByServer.get(server))) + ); + } + + for(Session session : sessions) { + if(session.end() != null) { + lines.add(formatLastSeen(session)); + } + } + + return lines; + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/freeze/FrozenPlayer.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/freeze/FrozenPlayer.java new file mode 100644 index 0000000..f481c65 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/freeze/FrozenPlayer.java @@ -0,0 +1,11 @@ +package tc.oc.commons.bukkit.freeze; + +/** + * Handle for a {@link org.bukkit.entity.Player} frozen by the {@link PlayerFreezer}. + * + * Multiple handles can exist for the same player simultaneously. The player remains + * frozen until ALL handles are thawed. + */ +public interface FrozenPlayer { + void thaw(); +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/freeze/PlayerFreezer.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/freeze/PlayerFreezer.java new file mode 100644 index 0000000..830c3ba --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/freeze/PlayerFreezer.java @@ -0,0 +1,102 @@ +package tc.oc.commons.bukkit.freeze; + +import java.time.Duration; +import java.util.Map; +import java.util.WeakHashMap; +import javax.inject.Inject; +import javax.inject.Singleton; + +import com.google.common.collect.HashMultimap; +import com.google.common.collect.SetMultimap; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.event.world.WorldUnloadEvent; +import tc.oc.commons.bukkit.util.NMSHacks; +import tc.oc.commons.core.plugin.PluginFacet; +import tc.oc.minecraft.api.scheduler.Tickable; + +/** + * Freezes players by mounting them on an invisible minecart. + */ +@Singleton +public class PlayerFreezer implements PluginFacet, Listener, Tickable { + + private final Map armorStands = new WeakHashMap<>(); + private final SetMultimap frozenPlayers = HashMultimap.create(); + + @Inject PlayerFreezer() {} + + @Override + public Duration tickPeriod() { + return Duration.ofMillis(50); + } + + private NMSHacks.FakeArmorStand armorStand(Player player) { + return armorStands.computeIfAbsent(player.getWorld(), NMSHacks.FakeArmorStand::new); + } + + public boolean isFrozen(Player player) { + return frozenPlayers.containsKey(player); + } + + public FrozenPlayer freeze(Player player) { + final FrozenPlayerImpl frozenPlayer = new FrozenPlayerImpl(player); + final boolean wasFrozen = isFrozen(player); + frozenPlayers.put(player, frozenPlayer); + + if(!wasFrozen) { + player.setPaused(true); + player.leaveVehicle(); // TODO: Put them back in the vehicle when thawed? + armorStand(player).spawn(player, player.getLocation()); + sendAttach(player); + } + + return frozenPlayer; + } + + @Override + public void tick() { + // If the player right-clicks on another vehicle while frozen, the client will + // eject them from the freeze entity unconditionally, so we have to spam them + // with these packets to keep them on it. + frozenPlayers.keySet().forEach(this::sendAttach); + } + + private void sendAttach(Player player) { + armorStand(player).ride(player, player); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onQuit(PlayerQuitEvent event) { + frozenPlayers.removeAll(event.getPlayer()); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onUnload(WorldUnloadEvent event) { + armorStands.remove(event.getWorld()); + } + + private class FrozenPlayerImpl implements FrozenPlayer { + // Might eventually put some state here that can be restored after thawing, + // e.g. gamemode, vehicle, etc. But currently, this class doesn't know enough + // about the player's situation to do that safely. + + private final Player player; + + private FrozenPlayerImpl(Player player) { + this.player = player; + } + + @Override + public void thaw() { + if(frozenPlayers.remove(player, this) && !isFrozen(player) && player.isOnline()) { + armorStand(player).destroy(player); + player.setPaused(false); + } + } + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/hologram/Hologram.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/hologram/Hologram.java new file mode 100644 index 0000000..e362cfe --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/hologram/Hologram.java @@ -0,0 +1,35 @@ +package tc.oc.commons.bukkit.hologram; + +import org.bukkit.entity.Player; +import org.bukkit.plugin.Plugin; +import tc.oc.commons.bukkit.hologram.content.HologramContent; + +/** + * Represents a text-based in-game hologram, which may be animated. + */ +public interface Hologram { + /** + * Sets the current hologram content. + * + * @param plugin The plugin responsible for the hologram. + * @param content The content to be displayed. + */ + public void setContent(Plugin plugin, HologramContent content); + + /** + * Displays the hologram to the specified player. + * + * @param player The player. + * @throws java.lang.IllegalStateException If no content has been set. + */ + public void show(Player player) throws IllegalStateException; + + /** + * Hides the hologram from the specified player. + * + * @param player The player. + */ + public void hide(Player player); + + public HologramContent getContent(); +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/hologram/HologramUtil.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/hologram/HologramUtil.java new file mode 100644 index 0000000..29fc800 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/hologram/HologramUtil.java @@ -0,0 +1,151 @@ +package tc.oc.commons.bukkit.hologram; + +import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableBiMap; +import org.apache.commons.lang.StringUtils; +import org.bukkit.ChatColor; + +import javax.annotation.Nullable; +import java.awt.*; +import java.awt.image.BufferedImage; +import java.util.LinkedList; + +/** + * Various {@link tc.oc.commons.bukkit.hologram.content.HologramFrame}-related utility methods + */ +public class HologramUtil { + public static final int MAX_CLOSEST = 255 * 255 * 3; + + /** + * The filler content used when transparency is detected inside of an image. + * + * @see FileFormat.info reference. + */ + public static final String ALPHA_FILLER_CONTENT = ChatColor.DARK_GRAY + " \u23B9"; + + /** + * The character used for non-transparent pixels inside of an image. + * + * @see UnicodeMap.org reference. + */ + public static final char PIXEL_CHAR = '\u2588'; + + /** + * A map of Minecraft chat colors to their closest RGB counterparts, created by asdjke. + */ + public static final ImmutableBiMap COLOR_MAP = ImmutableBiMap.builder() + .put(Color.BLACK, ChatColor.BLACK) + .put(new Color(0, 0, 170), ChatColor.DARK_BLUE) + .put(new Color(0, 170, 0), ChatColor.DARK_GREEN) + .put(new Color(0, 170, 170), ChatColor.DARK_AQUA) + .put(new Color(170, 0, 0), ChatColor.DARK_RED) + .put(new Color(170, 0, 170), ChatColor.DARK_PURPLE) + .put(new Color(255, 170, 0), ChatColor.GOLD) + .put(new Color(170, 170, 170), ChatColor.GRAY) + .put(new Color(85, 85, 85), ChatColor.DARK_GRAY) + .put(new Color(85, 85, 255), ChatColor.BLUE) + .put(new Color(85, 255, 85), ChatColor.GREEN) + .put(new Color(85, 255, 255), ChatColor.AQUA) + .put(new Color(255, 85, 85), ChatColor.RED) + .put(new Color(255, 85, 255), ChatColor.LIGHT_PURPLE) + .put(new Color(255, 255, 85), ChatColor.YELLOW) + .put(Color.WHITE, ChatColor.WHITE) + .build(); + + /** + * Converts a {@link java.awt.image.BufferedImage} to a multi-line text message, using {@link #COLOR_MAP}. + * + * @return A {@link java.lang.String[]} containing the message + */ + public static String[] imageToText(BufferedImage image, boolean trim) { + int height = Preconditions.checkNotNull(image, "Image").getHeight(); + int width = image.getWidth(); + + String[][] message = new String[height][width]; + LinkedList pendingAlpha = new LinkedList<>(); + for (int y = 0; y < height; y++) { + boolean fillAlpha = !trim; + boolean left = false; + + for (int x = 0; x < width; x++) { + Color color = new Color(image.getRGB(x, y), true); + + if (trim) { + if (color.getAlpha() < 1) { + pendingAlpha.add(x); + left = (left || x == 0); + } else { + if (!left) { + applyPendingAlpha(pendingAlpha, message[y]); + } else { + pendingAlpha.clear(); + left = false; + } + } + } + + ChatColor minecraftColor = rgbToMinecraft(closestColorMatch(color, COLOR_MAP.keySet())); + message[y][x] = minecraftColor == null ? (fillAlpha ? ALPHA_FILLER_CONTENT : "") : minecraftColor.toString() + PIXEL_CHAR; + } + + if (!trim) { + applyPendingAlpha(pendingAlpha, message[y]); + } + } + + String[] messageFinal = new String[height]; + for (int y = 0; y < height; y++) { + messageFinal[y] = StringUtils.join(message[y]); + } + + return messageFinal; + } + + /** + * Attempts to find the closest {@link ChatColor} for the given {@link Color}. + * + * @param color The color + * @return The appropriate chat color + */ + public static @Nullable ChatColor rgbToMinecraft(Color color) { + return COLOR_MAP.get(color); + } + + /** + * Finds the closest match for the specified {@link Color} from the provided colors. Created by asdjke. + * + * @param color The color to match + * @param colors The possible matches + * @return The closest match, or null if alpha is zero + */ + public static @Nullable Color closestColorMatch(Color color, Iterable colors) { + if (color.getAlpha() < 1) return null; + + int r = color.getRed(); + int g = color.getGreen(); + int b = color.getBlue(); + + int closest = MAX_CLOSEST; + Color best = null; + + for (Color key : colors) { + int rDist = Math.abs(r - key.getRed()); + int gDist = Math.abs(g - key.getGreen()); + int bDist = Math.abs(b - key.getBlue()); + int dist = rDist * rDist + gDist * gDist + bDist * bDist; + if (dist < closest) { + best = key; + closest = dist; + } + } + + return best; + } + + private static void applyPendingAlpha(LinkedList pendingAlpha, String[] message) { + for (int x : pendingAlpha) { + message[x] = ALPHA_FILLER_CONTENT; + } + pendingAlpha.clear(); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/hologram/content/HologramAnimation.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/hologram/content/HologramAnimation.java new file mode 100644 index 0000000..a899bd3 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/hologram/content/HologramAnimation.java @@ -0,0 +1,113 @@ +package tc.oc.commons.bukkit.hologram.content; + +import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableList; + +/** + * Represents an immutable multi-{@link tc.oc.commons.bukkit.hologram.content.HologramFrame} animation that may be + * displayed by a {@link tc.oc.commons.bukkit.hologram.Hologram}. + */ +public class HologramAnimation implements HologramContent { + public static boolean DEFAULT_LOOP = true; + public static long DEFAULT_FRAME_DELAY = 1; + public static long DEFAULT_START_DELAY = 0; + public static long DEFAULT_END_DELAY = 0; + + private final ImmutableList frames; + private final int maxHeight; + private final boolean loop; + private final long frameDelay; + private final long startDelay; + private final long endDelay; + + /** + * Creates a new animation with the specified parameters. + * + * @param loop Whether or not to loop the animation at its conclusion. + * @param frameDelay The delay, in ticks. + * @param frames The frames to be displayed. + */ + public HologramAnimation(boolean loop, long frameDelay, long startDelay, long endDelay, HologramFrame... frames) { + Preconditions.checkArgument(frameDelay >= 1, "Tick delay must be at least 1 (was {0})", frameDelay); + Preconditions.checkNotNull(frames, "Frames"); + Preconditions.checkArgument(frames.length > 1, "Frame count must be greater than 1 (was {0})", frames.length); + this.frames = ImmutableList.copyOf(frames); + + int largest = 0; + for (HologramFrame frame : this.frames) { + largest = Math.max(largest, frame.getHeight()); + } + this.maxHeight = largest; + + this.loop = loop; + this.frameDelay = frameDelay; + this.startDelay = startDelay; + this.endDelay = endDelay; + } + + /** + * Creates a new animation with the specified parameters and the default tick delay. + * + * @param loop Whether or not to loop the animation at its conclusion. + * @param frames The frames to be displayed. + * @see #DEFAULT_FRAME_DELAY + */ + public HologramAnimation(boolean loop, HologramFrame... frames) { + this(loop, DEFAULT_FRAME_DELAY, DEFAULT_START_DELAY, DEFAULT_END_DELAY, frames); + } + + /** + * Creates a new animation with the specified frames, and the defaults for loop status and tick delay. + * + * @param frames The frames to be displayed. + * @see #DEFAULT_LOOP + * @see #DEFAULT_FRAME_DELAY + */ + public HologramAnimation(HologramFrame... frames) { + this(DEFAULT_LOOP, DEFAULT_FRAME_DELAY, DEFAULT_START_DELAY, DEFAULT_END_DELAY, frames); + } + + /** + * Gets the frames of the animation. + * + * @return The frames of the animation. + */ + public ImmutableList getFrames() { + return this.frames; + } + + /** + * Gets whether or not the animation should loop. + * + * @return Whether or not the animation should loop. + */ + public boolean shouldLoop() { + return this.loop; + } + + /** + * Gets the number of ticks that the hologram should wait in between frames. + * + * @return The number of ticks that the hologram should wait in between frames. + */ + public long getFrameDelay() { + return this.frameDelay; + } + + public long getStartDelay() { + return this.startDelay; + } + + public long getEndDelay() { + return this.endDelay; + } + + /** + * Gets the vertical height of the tallest frame in the animation. + * + * @return The vertical height of the tallest frame in the animation. + */ + public int getMaxHeight() { + return this.maxHeight; + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/hologram/content/HologramContent.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/hologram/content/HologramContent.java new file mode 100644 index 0000000..2eb6043 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/hologram/content/HologramContent.java @@ -0,0 +1,6 @@ +package tc.oc.commons.bukkit.hologram.content; + +/** + * Represents content that may be displayed by a {@link tc.oc.commons.bukkit.hologram.Hologram}. + */ +public interface HologramContent {} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/hologram/content/HologramFrame.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/hologram/content/HologramFrame.java new file mode 100644 index 0000000..9f96dcf --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/hologram/content/HologramFrame.java @@ -0,0 +1,86 @@ +package tc.oc.commons.bukkit.hologram.content; + +import org.apache.commons.lang.ArrayUtils; +import org.apache.commons.lang.builder.ToStringBuilder; + +import java.util.Arrays; +import java.util.List; + +/** + * Represents a single frame of a {@link tc.oc.commons.bukkit.hologram.Hologram}. + */ +public class HologramFrame implements HologramContent { + private final List content; + + /** + * Creates a new {@link HologramFrame} with the specified content. + * + * @param content The content to be displayed + */ + public HologramFrame(String... content) { + ArrayUtils.reverse(content); + this.content = Arrays.asList(content); + } + + /** + * Gets the multi-line text content contained in the frame. + * + * @return The content + */ + public List getContent() { + return this.content; + } + + /** + * Gets the height of the frame. + * + * @return The height of the frame. + */ + public int getHeight() { + return this.content.size(); + } + + /** + * Gets the width of the longest row in the frame. + * + * @return The width of the longest row in the frame. + */ + public int getMaxWidth() { + int largest = 0; + for (String row : this.content) { + largest = Math.max(largest, row.length()); + } + + return largest; + } + + /** + * Gets the width of the shortest row in the frame. + * + * @return The width of the shortest row in the frame. + */ + public int getMinWidth() { + int smallest = Integer.MAX_VALUE; + for (String row : this.content) { + smallest = Math.min(smallest, row.length()); + } + + return smallest; + } + + @Override + public boolean equals(Object obj) { + if (!HologramFrame.class.isInstance(obj)) return false; + + HologramFrame hologramFrame = (HologramFrame) obj; + List content = hologramFrame.getContent(); + return this == hologramFrame || (this.content.size() == content.size() && this.content.containsAll(content)); + } + + @Override + public String toString() { + return new ToStringBuilder(this) + .append("content", content.toArray()) + .toString(); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/listeners/AppealAlertListener.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/listeners/AppealAlertListener.java new file mode 100644 index 0000000..0ebe166 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/listeners/AppealAlertListener.java @@ -0,0 +1,45 @@ +package tc.oc.commons.bukkit.listeners; + +import javax.inject.Inject; + +import net.md_5.bungee.api.ChatColor; +import net.md_5.bungee.api.chat.TranslatableComponent; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import tc.oc.commons.bukkit.chat.Audiences; +import tc.oc.commons.bukkit.event.UserLoginEvent; +import tc.oc.commons.bukkit.format.MiscFormatter; +import tc.oc.commons.core.chat.Component; +import tc.oc.commons.core.plugin.PluginFacet; + +/** + * Nag staff about unread appeals when they login + */ +public class AppealAlertListener implements PluginFacet, Listener { + + private static final String PERMISSION = "projectares.appeals.alerts"; + + private final MiscFormatter miscFormatter; + private final Audiences audiences; + + @Inject private AppealAlertListener(MiscFormatter miscFormatter, Audiences audiences) { + this.miscFormatter = miscFormatter; + this.audiences = audiences; + } + + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + public void onLogin(UserLoginEvent event) { + final int count = event.response().unread_appeal_count(); + if(count > 0 && event.getPlayer().hasPermission(PERMISSION)) { + audiences.get(event.getPlayer()).sendMessage( + new Component(ChatColor.RED) + .extra(miscFormatter.typePrefix("A")) + .translate("appealNotification.count", + new Component(count, ChatColor.AQUA), + new TranslatableComponent(count == 1 ? "misc.appeals.singular" + : "misc.appeals.plural")) + ); + } + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/listeners/InactivePlayerListener.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/listeners/InactivePlayerListener.java new file mode 100644 index 0000000..3c6145c --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/listeners/InactivePlayerListener.java @@ -0,0 +1,142 @@ +package tc.oc.commons.bukkit.listeners; + +import java.util.Map; +import java.util.Objects; +import javax.annotation.Nullable; +import javax.inject.Inject; +import javax.inject.Singleton; + +import org.bukkit.ChatColor; +import org.bukkit.Sound; +import org.bukkit.configuration.Configuration; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerMoveEvent; +import java.time.Duration; +import java.time.Instant; +import tc.oc.commons.bukkit.configuration.ConfigUtils; +import tc.oc.commons.bukkit.localization.CommonsTranslations; +import tc.oc.commons.bukkit.teleport.PlayerServerChanger; +import tc.oc.commons.bukkit.util.OnlinePlayerMapAdapter; +import tc.oc.commons.core.exception.ExceptionHandler; +import tc.oc.commons.core.plugin.PluginFacet; +import tc.oc.minecraft.api.scheduler.Tickable; + +@Singleton +public class InactivePlayerListener implements Listener, PluginFacet, Tickable { + + public static class Config { + private final Configuration config; + private final ExceptionHandler exceptionHandler; + + @Inject Config(Configuration config, ExceptionHandler exceptionHandler) { + this.config = config; + this.exceptionHandler = exceptionHandler; + } + + public boolean enabled() { + return timeout() != null; + } + + public Duration timeout() { + return ConfigUtils.getDuration(config, "afk.timeout"); + } + + public Duration warning() { + return ConfigUtils.getDuration(config, "afk.warning"); + } + + public java.time.Duration interval() { + return exceptionHandler.flatGet(() -> config.duration("afk.interval")) + .orElse(java.time.Duration.ofSeconds(10)); + } + } + + private static final String AFK_FOREVER_PERM = "afk.forever"; + private final Config config; + private final PlayerServerChanger playerServerChanger; + + private final OnlinePlayerMapAdapter lastActivity; + private @Nullable Instant lastCheck; + + @Inject InactivePlayerListener(Config config, PlayerServerChanger playerServerChanger, OnlinePlayerMapAdapter lastActivity) { + this.config = config; + this.playerServerChanger = playerServerChanger; + this.lastActivity = lastActivity; + } + + @Override + public boolean isActive() { + return config.enabled(); + } + + @Override + public java.time.Duration tickPeriod() { + return config.interval(); + } + + @Override + public void enable() { + lastActivity.enable(); + } + + @Override + public void disable() { + lastActivity.disable(); + } + + private void activity(Player player) { + if(player.hasPermission(AFK_FOREVER_PERM)) { + this.lastActivity.remove(player); + } else { + this.lastActivity.put(player, Instant.now()); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void join(PlayerJoinEvent event) { + this.activity(event.getPlayer()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void move(PlayerMoveEvent event) { + if(!Objects.equals(event.getFrom(), event.getTo())) { + this.activity(event.getPlayer()); + } + } + + @Override + public void tick() { + final Duration timeout = config.timeout(); + final Duration warning = config.warning(); + + Instant now = Instant.now(); + Instant kickTime = now.minus(timeout); + Instant warnTime = warning == null ? null : now.minus(warning); + Instant lastWarnTime = warning == null || lastCheck == null ? null : lastCheck.minus(warning); + + // Iterate over a copy, because kicking players while iterating the original + // OnlinePlayerMapAdapter throws a ConcurrentModificationException + for(Map.Entry entry : lastActivity.entrySetCopy()) { + Player player = entry.getKey(); + Instant time = entry.getValue(); + + if(time.isBefore(kickTime)) { + playerServerChanger.kickPlayer(player, CommonsTranslations.get().t("afk.kick", player)); + } else if(warnTime != null && time.isAfter(lastWarnTime) && !time.isAfter(warnTime)) { + player.playSound(player.getLocation(), Sound.BLOCK_NOTE_PLING, 1, 1); + player.sendMessage(ChatColor.RED.toString() + ChatColor.BOLD + CommonsTranslations.get().t( + "afk.warn", player, + ChatColor.AQUA.toString() + ChatColor.BOLD + + timeout.minus(warning).getSeconds() + + ChatColor.RED + ChatColor.BOLD + )); + } + } + + lastCheck = now; + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/listeners/LocaleListener.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/listeners/LocaleListener.java new file mode 100644 index 0000000..73202dd --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/listeners/LocaleListener.java @@ -0,0 +1,39 @@ +package tc.oc.commons.bukkit.listeners; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerLocaleChangeEvent; +import tc.oc.api.bukkit.users.BukkitUserStore; +import tc.oc.api.docs.virtual.UserDoc; +import tc.oc.api.users.UserService; +import tc.oc.commons.core.plugin.PluginFacet; + +/** + * Saves the player's locale on {@link PlayerLocaleChangeEvent}, which fires when + * the server receives a ClientSettings packet, and the locale is different from + * the current value. The client always sends this packet just after connecting, + * and Bungee also re-sends the packet on every server change. + * + * Because we initialize the locale from the DB on login, we normally won't get + * this event. It will only fire when the player has actually changed their locale. + */ +@Singleton +public class LocaleListener implements Listener, PluginFacet { + + private final BukkitUserStore userStore; + private final UserService userService; + + @Inject LocaleListener(BukkitUserStore userStore, UserService userService) { + this.userStore = userStore; + this.userService = userService; + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onLocaleChange(PlayerLocaleChangeEvent event) { + userService.update(userStore.getUser(event.getPlayer()), (UserDoc.Locale) event::getNewLocale); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/listeners/LoginListener.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/listeners/LoginListener.java new file mode 100644 index 0000000..decb738 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/listeners/LoginListener.java @@ -0,0 +1,252 @@ +package tc.oc.commons.bukkit.listeners; + +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.ReadWriteLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; +import java.util.logging.Level; +import java.util.logging.Logger; +import javax.inject.Inject; +import javax.inject.Singleton; + +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; +import net.md_5.bungee.api.chat.BaseComponent; +import net.md_5.bungee.api.chat.TranslatableComponent; +import org.apache.commons.lang3.mutable.MutableBoolean; +import org.bukkit.entity.Player; +import org.bukkit.event.EventBus; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.AsyncPlayerPreLoginEvent; +import org.bukkit.event.player.PlayerLoginEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.permissions.PermissionAttachment; +import org.bukkit.plugin.Plugin; +import tc.oc.api.bukkit.users.BukkitUserStore; +import tc.oc.api.docs.Server; +import tc.oc.api.docs.virtual.UserDoc; +import tc.oc.api.minecraft.MinecraftService; +import tc.oc.api.users.LoginRequest; +import tc.oc.api.users.LoginResponse; +import tc.oc.api.users.UserService; +import tc.oc.api.util.Permissions; +import tc.oc.commons.bukkit.chat.ComponentRenderContext; +import tc.oc.commons.bukkit.event.AsyncUserLoginEvent; +import tc.oc.commons.bukkit.event.UserLoginEvent; +import tc.oc.commons.bukkit.punishment.PunishmentFormatter; +import tc.oc.commons.bukkit.util.PermissionUtils; +import tc.oc.commons.core.chat.Component; +import tc.oc.commons.core.concurrent.Locker; +import tc.oc.commons.core.logging.Loggers; +import tc.oc.commons.core.plugin.PluginFacet; +import tc.oc.minecraft.api.scheduler.Scheduler; +import tc.oc.minecraft.protocol.MinecraftVersion; + +@Singleton +public class LoginListener implements Listener, PluginFacet { + + private static final String INTERNAL_SERVER_ERROR = "Sorry, but there was an internal server error.\n" + + "We are working to resolve the issue: please check back soon."; + private static final String SERVER_IS_RESTARTING = "Server is restarting, please reconnect in a moment"; + + private final Logger logger; + private final Plugin plugin; + private final EventBus eventBus; + private final Scheduler scheduler; + private final MinecraftService minecraftService; + private final UserService userService; + private final BukkitUserStore userStore; + private final ComponentRenderContext renderer; + private final PunishmentFormatter punishmentFormatter; + + private boolean connected; + private final ReadWriteLock connectedLock = new ReentrantReadWriteLock(); + + // MC login times out in 30 seconds so caching for 1 minute should be fine + private final Cache logins = CacheBuilder.newBuilder() + .expireAfterWrite(1, TimeUnit.MINUTES) + .build(); + + @Inject LoginListener(Loggers loggers, Plugin plugin, EventBus eventBus, Scheduler scheduler, MinecraftService minecraftService, UserService userService, BukkitUserStore userStore, ComponentRenderContext renderer, PunishmentFormatter punishmentFormatter) { + this.eventBus = eventBus; + this.logger = loggers.get(getClass()); + this.scheduler = scheduler; + this.minecraftService = minecraftService; + this.userService = userService; + this.userStore = userStore; + this.plugin = plugin; + this.renderer = renderer; + this.punishmentFormatter = punishmentFormatter; + } + + @Override + public void enable() { + try(Locker _ = Locker.lock(connectedLock.writeLock())) { + connected = true; + } + } + + @Override + public void disable() { + try(Locker _ = Locker.lock(connectedLock.writeLock())) { + connected = false; + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void preLogin(final AsyncPlayerPreLoginEvent event) { + this.logger.info(event.getName() + " pre-login: uuid=" + event.getUniqueId() + " ip=" + event.getAddress()); + + try(Locker _ = Locker.lock(connectedLock.readLock())) { + this.logins.invalidate(event.getUniqueId()); + + if(!connected) { + event.disallow(AsyncPlayerPreLoginEvent.Result.KICK_OTHER, SERVER_IS_RESTARTING); + return; + } + + LoginResponse response = this.userService.login( + new LoginRequest(event.getName(), + event.getUniqueId(), + event.getAddress(), + minecraftService.getLocalServer(), + true) + ).get(); + + if(response.kick() != null) switch(response.kick()) { + case "error": + this.logger.info(event.getName() + " login error: " + response.message()); + event.disallow(AsyncPlayerPreLoginEvent.Result.KICK_OTHER, response.message()); + break; + + case "banned": // Only used for IP bans right now + this.logger.info(event.getName() + " is banned"); + event.disallow(AsyncPlayerPreLoginEvent.Result.KICK_BANNED, response.message()); + break; + } + + if(event.getLoginResult() != AsyncPlayerPreLoginEvent.Result.ALLOWED) return; + + this.logins.put(event.getUniqueId(), response); + + eventBus.callEvent(new AsyncUserLoginEvent(response)); + } catch(Exception e) { + this.logger.log(Level.SEVERE, e.toString(), e); + event.disallow(AsyncPlayerPreLoginEvent.Result.KICK_OTHER, INTERNAL_SERVER_ERROR); + } + } + + @EventHandler(priority = EventPriority.LOWEST) + public void login(PlayerLoginEvent event) { + try { + final Player player = event.getPlayer(); + final UUID uuid = player.getUniqueId(); + + this.logins.cleanUp(); + final LoginResponse response = this.logins.getIfPresent(uuid); + this.logins.invalidate(uuid); + + if(response == null) { + this.logger.warning("No login info for " + player.getName() + " " + uuid); + event.disallow(PlayerLoginEvent.Result.KICK_OTHER, INTERNAL_SERVER_ERROR); + return; + } + + // TODO: Consider creating a PreUserLoginEvent that can be cancelled, + // before things like sessions are started. + + userStore.addUser(player, response.user()); + + applyPermissions(player, response.user()); + + if(response.punishment() != null) { + rejectLogin(event, punishmentFormatter.screen(response.punishment())); + } + + if(!player.hasPermission(Permissions.LOGIN)) { + rejectLogin(event, new TranslatableComponent("servers.notAllowed")); + } + + if(event.getResult() == PlayerLoginEvent.Result.KICK_FULL) { + // Allow privileged players to join when the server is full + if(player.hasPermission("pgm.fullserver")) { + event.allow(); + } else { + rejectLogin(event, new TranslatableComponent("serverFull")); + } + } + + if(response.user().mc_locale() != null) { + // If we have a saved locale for the player, apply it. + // This should ensure that text displayed on join is properly + // localized, as long as the player has connected once before. + player.setLocale(response.user().mc_locale()); + } + + userService.update(response.user(), new UserDoc.ClientDetails() { + @Override public String mc_client_version() { + return MinecraftVersion.describeProtocol(player.getProtocolVersion()); + } + + @Override public String skin_blob() { + return player.getSkin().getData(); + } + }); + + if(event.getResult() == PlayerLoginEvent.Result.KICK_OTHER) return; + + final UserLoginEvent ourEvent = new UserLoginEvent( + player, response, event.getResult(), + event.getKickMessage() == null || "".equals(event.getKickMessage()) ? null : new Component(event.getKickMessage()) + ); + + eventBus.callEvent(ourEvent); + + event.setResult(ourEvent.getResult()); + event.setKickMessage(ourEvent.getKickMessage() == null ? "" : renderer.renderLegacy(ourEvent.getKickMessage(), player)); + } + catch(Exception e) { + this.logger.log(Level.SEVERE, e.toString(), e); + event.disallow(PlayerLoginEvent.Result.KICK_OTHER, INTERNAL_SERVER_ERROR); + } + } + + protected void applyPermissions(Player player, UserDoc.Login userDoc) { + boolean op = false; + + final Server localServer = minecraftService.getLocalServer(); + if(localServer.operators().containsKey(player.getUniqueId())) { + logger.info("Opping " + player.getName() + " because they are in the server op list"); + op = true; + } + + if(localServer.team() != null && localServer.team().members().contains(userDoc)) { + logger.info("Opping " + player.getName() + " because they are on the team that owns the server"); + op = true; + } + + PermissionAttachment attachment = player.addAttachment(this.plugin); + PermissionUtils.setPermissions(attachment, Permissions.mergePermissions(localServer.realms(), userDoc.mc_permissions_by_realm())); + player.recalculatePermissions(); + + if(player.hasPermission("op")) { + op = true; + logger.info("Opping " + player.getName() + " because they have the op permission node"); + } + + player.setOp(op); // This is always explicitly set to true or false on login + } + + protected void rejectLogin(PlayerLoginEvent event, BaseComponent message) { + if(event.getResult() != PlayerLoginEvent.Result.KICK_OTHER) { + event.disallow(PlayerLoginEvent.Result.KICK_OTHER, renderer.renderLegacy(message, event.getPlayer())); + } + } + + @EventHandler + private void quit(PlayerQuitEvent event) { + scheduler.runSync(() -> userStore.removeUser(event.getPlayer())); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/listeners/PermissionGroupListener.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/listeners/PermissionGroupListener.java new file mode 100644 index 0000000..94698fe --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/listeners/PermissionGroupListener.java @@ -0,0 +1,63 @@ +package tc.oc.commons.bukkit.listeners; + +import java.util.Map; +import java.util.Objects; +import javax.annotation.Nullable; +import javax.inject.Inject; + +import com.google.common.eventbus.Subscribe; +import org.bukkit.permissions.Permissible; +import org.bukkit.permissions.Permission; +import org.bukkit.permissions.PermissionDefault; +import org.bukkit.plugin.PluginManager; +import tc.oc.api.bukkit.users.OnlinePlayers; +import tc.oc.api.docs.Server; +import tc.oc.api.minecraft.servers.LocalServerReconfigureEvent; +import tc.oc.api.util.Permissions; +import tc.oc.commons.core.plugin.PluginFacet; + +/** + * Create and update magic permission groups from the local server document + */ +public class PermissionGroupListener implements PluginFacet { + + private final PluginManager pluginManager; + private final Server localServer; + private final OnlinePlayers onlinePlayers; + + @Inject PermissionGroupListener(PluginManager pluginManager, Server localServer, OnlinePlayers onlinePlayers) { + this.pluginManager = pluginManager; + this.localServer = localServer; + this.onlinePlayers = onlinePlayers; + } + + @Override + public void enable() { + updateServer(null, localServer); + } + + @Subscribe + public void onReconfigure(LocalServerReconfigureEvent event) { + updateServer(event.getOldConfig(), event.getNewConfig()); + } + + private void updateServer(@Nullable Server before, Server after) { + boolean dirty = false; + dirty |= updatePermission(Permissions.OBSERVER, before == null ? null : before.observer_permissions(), after.observer_permissions()); + dirty |= updatePermission(Permissions.PARTICIPANT, before == null ? null : before.participant_permissions(), after.participant_permissions()); + dirty |= updatePermission(Permissions.MAPMAKER, before == null ? null : before.mapmaker_permissions(), after.mapmaker_permissions()); + + if(dirty) { + onlinePlayers.all().forEach(Permissible::recalculatePermissions); + } + } + + private boolean updatePermission(String name, Map before, Map after) { + if(Objects.equals(before, after)) return false; + + final Permission perm = new Permission(name, PermissionDefault.FALSE, after); + pluginManager.removePermission(perm); + pluginManager.addPermission(perm); + return true; + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/localization/CommonsTranslations.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/localization/CommonsTranslations.java new file mode 100644 index 0000000..dd089eb --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/localization/CommonsTranslations.java @@ -0,0 +1,17 @@ +package tc.oc.commons.bukkit.localization; + +import tc.oc.commons.core.localization.TranslationSet; + +public class CommonsTranslations extends PluginTranslations { + private static CommonsTranslations instance; + + private CommonsTranslations() { + super(new TranslationSet("commons.Commons")); + instance = this; + } + + public static CommonsTranslations get() { + return instance == null ? new CommonsTranslations() : instance; + } + +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/localization/LocalizationManifest.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/localization/LocalizationManifest.java new file mode 100644 index 0000000..326c034 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/localization/LocalizationManifest.java @@ -0,0 +1,21 @@ +package tc.oc.commons.bukkit.localization; + +import java.util.Map; + +import com.google.inject.TypeLiteral; +import net.md_5.bungee.api.chat.BaseComponent; +import tc.oc.commons.core.inject.Manifest; +import tc.oc.commons.core.reflect.TypeLiterals; +import tc.oc.parse.ParserTypeLiterals; + +public class LocalizationManifest extends Manifest implements TypeLiterals, ParserTypeLiterals { + + @Override + protected void configure() { + installFactory(LocalizedMessageMap.Factory.class); + installFactory(new TypeLiteral>>(){}); + + bind(DocumentParser(Map(String.class, BaseComponent.class))) + .to(MessageMapParser.class); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/localization/LocalizedDocument.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/localization/LocalizedDocument.java new file mode 100644 index 0000000..440aac7 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/localization/LocalizedDocument.java @@ -0,0 +1,110 @@ +package tc.oc.commons.bukkit.localization; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.Locale; +import java.util.Optional; +import java.util.logging.Level; +import java.util.stream.Stream; +import javax.annotation.Nullable; +import javax.inject.Inject; +import javax.xml.parsers.DocumentBuilder; + +import com.google.common.cache.LoadingCache; +import com.google.inject.assistedinject.Assisted; +import org.xml.sax.SAXException; +import tc.oc.commons.bukkit.logging.MapdevLogger; +import tc.oc.minecraft.scheduler.MainThreadExecutor; +import tc.oc.commons.core.localization.Locales; +import tc.oc.commons.core.localization.LocalizedFileManager; +import tc.oc.commons.core.util.CacheUtils; +import tc.oc.file.PathWatcher; +import tc.oc.file.PathWatcherHandle; +import tc.oc.file.PathWatcherService; +import tc.oc.parse.ParseException; +import tc.oc.parse.xml.DocumentParser; + +/** + * Monitors a localized file that can be parsed with a {@link DocumentParser} (which must have a binding) + * + * Each localized version of the file is monitored and parsed immediately when changes are detected. + */ +public class LocalizedDocument { + + public interface Factory { + LocalizedDocument create(@Assisted("source") Path source, @Assisted("localized") Path localized); + } + + private final Path sourcePath; + private final Path localizedPath; + private final PathWatcherService watcherService; + private final LocalizedFileManager localizedFiles; + private final DocumentParser parser; + private final DocumentBuilder builder; + private final MapdevLogger mapdevLogger; + private final MainThreadExecutor mainThread; + + private final LoadingCache watchers = CacheUtils.newCache(Watcher::new); + + @Inject LocalizedDocument(@Assisted("source") Path sourcePath, @Assisted("localized") Path localizedPath, DocumentParser parser, PathWatcherService watcherService, LocalizedFileManager localizedFiles, DocumentBuilder builder, MapdevLogger mapdevLogger, MainThreadExecutor mainThread) { + this.sourcePath = sourcePath; + this.localizedPath = localizedPath; + this.parser = parser; + this.watcherService = watcherService; + this.localizedFiles = localizedFiles; + this.builder = builder; + this.mapdevLogger = mapdevLogger; + this.mainThread = mainThread; + } + + public void disable() { + watchers.asMap().values() + .forEach(watcher -> watcher.handle.cancel()); + } + + public Optional getDefault() { + return Optional.ofNullable(watchers.getUnchecked(Locales.DEFAULT_LOCALE).document); + } + + public Stream get(Locale locale) { + return Stream.concat(localizedFiles.match(locale), + Stream.of(Locales.DEFAULT_LOCALE)) + .map(l -> watchers.getUnchecked(l).document) + .filter(d -> d != null); + } + + private class Watcher implements PathWatcher { + final Locale locale; + final PathWatcherHandle handle; + + @Nullable T document; + + private Watcher(Locale locale) throws IOException { + this.locale = locale; + final Path path = Locales.isDefault(locale) + ? sourcePath + : localizedFiles.localizedPath(locale, localizedPath); + this.handle = watcherService.watch(path, mainThread, this); + } + + @Override + public void fileCreated(Path path) { + fileModified(path); + } + + @Override + public void fileModified(Path path) { + try { + document = parser.parse(builder.parse(path.toFile())); + } catch(SAXException | IOException | ParseException e) { + mapdevLogger.log(Level.SEVERE, "Failed to load " + path + " for locale " + locale, e); + } + } + + @Override + public void fileDeleted(Path path) { + document = null; + } + } + +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/localization/LocalizedMessageMap.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/localization/LocalizedMessageMap.java new file mode 100644 index 0000000..0dc8fc6 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/localization/LocalizedMessageMap.java @@ -0,0 +1,93 @@ +package tc.oc.commons.bukkit.localization; + +import java.nio.file.Path; +import java.util.AbstractMap; +import java.util.Collection; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import javax.inject.Inject; + +import com.google.common.collect.Collections2; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Maps; +import com.google.inject.assistedinject.Assisted; +import net.md_5.bungee.api.chat.BaseComponent; +import org.bukkit.command.CommandSender; +import tc.oc.commons.bukkit.chat.ComponentRenderContext; +import tc.oc.commons.bukkit.chat.RenderableComponent; +import tc.oc.commons.core.chat.ImmutableComponent; +import tc.oc.commons.core.localization.Locales; + +public class LocalizedMessageMap extends AbstractMap { + + public interface Factory { + LocalizedMessageMap create(@Assisted("source") Path sourcePath, + @Assisted("localized") Path localizedPath); + } + + private final LocalizedDocument> document; + + @Inject LocalizedMessageMap(@Assisted("source") Path sourcePath, @Assisted("localized") Path localizedPath, LocalizedDocument.Factory> factory) { + this.document = factory.create(sourcePath, localizedPath); + } + + public void disable() { + document.disable(); + } + + @Override + public int size() { + return document.getDefault().map(Map::size).orElse(0); + } + + @Override + public boolean isEmpty() { + return !document.getDefault().filter(map -> !map.isEmpty()).isPresent(); + } + + @Override + public boolean containsKey(Object key) { + return document.getDefault().filter(map -> map.containsKey(key)).isPresent(); + } + + @Override + public Set keySet() { + return document.getDefault().map(Map::keySet).orElseGet(ImmutableSet::of); + } + + @Override + public Collection values() { + return Collections2.transform(keySet(), this::get); + } + + @Override + public Set> entrySet() { + return Maps.asMap(keySet(), this::get).entrySet(); + } + + @Override + public BaseComponent get(Object key) { + return key instanceof String ? new Component((String) key) : null; + } + + private class Component extends ImmutableComponent implements RenderableComponent { + final String key; + + Component(String key) { + this.key = key; + } + + @Override + public BaseComponent render(ComponentRenderContext context, CommandSender viewer) { + final Locale locale = Locales.locale(viewer); + return document.get(locale) + .map(map -> map.get(key)) + .filter(c -> c != null) + .findFirst() + .map(c -> context.render(c, viewer)) + .orElseThrow(() -> new IllegalStateException("Can't find localized message " + key + + " for locale " + locale)); + } + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/localization/MessageMapParser.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/localization/MessageMapParser.java new file mode 100644 index 0000000..c7685c3 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/localization/MessageMapParser.java @@ -0,0 +1,28 @@ +package tc.oc.commons.bukkit.localization; + +import java.util.Map; +import javax.inject.Inject; + +import net.md_5.bungee.api.chat.BaseComponent; +import org.w3c.dom.Document; +import tc.oc.commons.bukkit.markup.MarkupParser; +import tc.oc.commons.core.stream.Collectors; +import tc.oc.parse.ParseException; +import tc.oc.parse.xml.DocumentParser; +import tc.oc.parse.xml.XML; + +public class MessageMapParser implements DocumentParser> { + + private final MarkupParser markupParser; + + @Inject MessageMapParser(MarkupParser markupParser) { + this.markupParser = markupParser; + } + + @Override + public Map parse(Document document) throws ParseException { + return XML.childrenNamed(document.getDocumentElement(), "string") + .collect(Collectors.toImmutableMap(el -> XML.requireAttr(el, "name").getValue(), + markupParser::content)); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/localization/MessageTemplate.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/localization/MessageTemplate.java new file mode 100644 index 0000000..ff76659 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/localization/MessageTemplate.java @@ -0,0 +1,86 @@ +package tc.oc.commons.bukkit.localization; + +import java.text.MessageFormat; +import java.util.Locale; +import javax.inject.Inject; + +import org.bukkit.command.CommandSender; +import tc.oc.commons.core.localization.Locales; + +/** + * Encapsulates a localized message template across all languages. + * + * Can be converted to a {@link MessageFormat}s by providing a specific {@link Locale}. + */ +public interface MessageTemplate { + + /** + * Is this template actually translated into different languages? + */ + boolean isLocalized(); + + MessageFormat format(Locale locale); + + default MessageFormat format() { + return format(Locales.DEFAULT_LOCALE); + } + + default MessageFormat format(CommandSender viewer) { + return format(PluginLocales.locale(viewer)); + } + + class Factory { + private final Translator translator; + + @Inject Factory(Translator translator) { + this.translator = translator; + } + + /** + * Create a {@link MessageTemplate} that returns the given {@link MessageFormat} for all locales. + */ + public MessageTemplate literal(MessageFormat message) { + return new MessageTemplate() { + @Override + public boolean isLocalized() { + return false; + } + + @Override + public MessageFormat format(Locale locale) { + return message; + } + + @Override + public String toString() { + return MessageTemplate.class.getSimpleName() + "{text=" + message + "}"; + } + }; + } + + /** + * Create a localized {@link MessageTemplate} from the given message key. + */ + public MessageTemplate fromKey(String key) { + if(!translator.hasKey(key)) { + throw new IllegalArgumentException("Missing translation key '" + key + "'"); + } + return new MessageTemplate() { + @Override + public boolean isLocalized() { + return true; + } + + @Override + public MessageFormat format(Locale locale) { + return translator.pattern(key, locale).get(); + } + + @Override + public String toString() { + return MessageTemplate.class.getSimpleName() + "{key=" + key + "}"; + } + }; + } + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/localization/PluginLocales.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/localization/PluginLocales.java new file mode 100644 index 0000000..d5ab513 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/localization/PluginLocales.java @@ -0,0 +1,19 @@ +package tc.oc.commons.bukkit.localization; + +import java.util.Locale; +import javax.annotation.Nullable; + +import org.bukkit.command.CommandSender; +import tc.oc.commons.core.localization.LocaleMatcher; +import tc.oc.commons.core.localization.Locales; + +public final class PluginLocales { + private PluginLocales() {} + + private static final LocaleMatcher LOCALE_MATCHER = new LocaleMatcher(Locales.DEFAULT_LOCALE, + Translations.get().supportedLocales()); + + public static Locale locale(@Nullable CommandSender sender) { + return LOCALE_MATCHER.closestMatchFor(Locales.locale(sender)); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/localization/PluginTranslations.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/localization/PluginTranslations.java new file mode 100644 index 0000000..fe22cc0 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/localization/PluginTranslations.java @@ -0,0 +1,96 @@ +package tc.oc.commons.bukkit.localization; + +import java.text.MessageFormat; +import java.util.Locale; +import java.util.NavigableSet; +import java.util.Optional; +import java.util.Set; +import java.util.TreeSet; +import javax.annotation.Nullable; + +import com.google.common.cache.LoadingCache; +import com.google.common.collect.ImmutableSet; +import org.bukkit.command.CommandSender; +import tc.oc.commons.core.localization.Locales; +import tc.oc.commons.core.localization.TranslationSet; +import tc.oc.commons.core.util.CacheUtils; + +/** + * Manages the {@link TranslationSet}s for a specific plugin. + */ +public abstract class PluginTranslations implements Translator { + + private final Set translationSets; + private final LoadingCache> setsByKey; + + public PluginTranslations(TranslationSet... sets) { + translationSets = ImmutableSet.copyOf(sets); + setsByKey = CacheUtils.newCache(key -> translationSets.stream() + .filter(set -> set.hasKey(key)) + .findFirst()); + } + + protected Set translationSets() { + return translationSets; + } + + @Override + public boolean hasKey(Locale locale, String key) { + for(TranslationSet translations : translationSets()) { + if(translations.hasKey(locale, key)) return true; + } + return false; + } + + @Override + public boolean hasKey(String key) { + return hasKey(Locales.DEFAULT_LOCALE, key); + } + + @Override + public NavigableSet getKeys(Locale locale, @Nullable String prefix) { + NavigableSet keys = new TreeSet<>(); + for(TranslationSet translations : translationSets()) { + for(String key : translations.getKeys(locale)) { + if(prefix == null || key.startsWith(prefix)) keys.add(key); + } + } + return keys; + } + + @Override + public NavigableSet getKeys(@Nullable String prefix) { + return getKeys(Locales.DEFAULT_LOCALE, prefix); + } + + @Override + public String t(String key, @Nullable CommandSender sender, Object... arguments) { + return pattern(key, PluginLocales.locale(sender)) + .map(format -> format.format(arguments)) + .orElseGet(() -> ""); + } + + @Override + public String t(String format, String key, @Nullable CommandSender viewer, Object... arguments) { + for(int i = 0; i < arguments.length; i++) { + arguments[i] = String.valueOf(arguments[i]) + format; + } + return format + this.t(key, viewer, arguments); + } + + @Override + public Optional pattern(String key) { + return pattern(key, Locales.DEFAULT_LOCALE); + } + + @Override + public Optional pattern(String key, Locale locale) { + return setsByKey.getUnchecked(key) + .flatMap(set -> set.pattern(key, locale)); + } + + @Override + public Optional pattern(String key, CommandSender sender) { + return pattern(key, PluginLocales.locale(sender)); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/localization/Translations.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/localization/Translations.java new file mode 100644 index 0000000..87e0a55 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/localization/Translations.java @@ -0,0 +1,91 @@ +package tc.oc.commons.bukkit.localization; + +import java.util.Collection; +import java.util.Iterator; +import java.util.Locale; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import javax.inject.Inject; +import javax.inject.Singleton; + +import org.bukkit.command.CommandSender; +import tc.oc.api.docs.virtual.MapDoc; +import tc.oc.commons.core.localization.Locales; +import tc.oc.commons.core.localization.TranslationSet; + +import static com.google.common.base.Preconditions.checkState; + +/** + * Contains translations for all plugins. This should be used instead of the + * individual plugin classes. + */ +@Singleton +public class Translations extends PluginTranslations { + private static Translations instance; + + @Inject Translations() { + super( + new TranslationSet("chatmoderator.ChatModeratorErrors"), + new TranslationSet("chatmoderator.ChatModeratorMessages"), + new TranslationSet("adminchat.AdminChatErrors"), + new TranslationSet("adminchat.AdminChatMessages"), + new TranslationSet("commons.Commons"), + new TranslationSet("pgm.PGMErrors"), + new TranslationSet("pgm.PGMMessages"), + new TranslationSet("pgm.PGMMiscellaneous"), + new TranslationSet("pgm.PGMUI"), + new TranslationSet("pgm.PGMDeath"), + new TranslationSet("lobby.LobbyErrors"), + new TranslationSet("lobby.LobbyMessages"), + new TranslationSet("lobby.LobbyMiscellaneous"), + new TranslationSet("lobby.LobbyUI"), + new TranslationSet("projectares.PAErrors"), + new TranslationSet("projectares.PAMessages"), + new TranslationSet("projectares.PAMiscellaneous"), + new TranslationSet("projectares.PAUI"), + new TranslationSet("raindrops.RaindropsMessages"), + new TranslationSet("tourney.Tourney") + ); + instance = this; + } + + public static Translations get() { + checkState(instance != null, Translations.class + " not initialized"); + return instance; + } + + public Set supportedLocales() { + return Stream.concat(Stream.of(Locales.DEFAULT_LOCALE, new Locale("af", "ZA")), + Stream.of(Locale.getAvailableLocales())) + .filter(locale -> translationSets().stream().anyMatch(set -> set.hasLocale(locale))) + .collect(Collectors.toSet()); + } + + public static String gamemodeShortName(MapDoc.Gamemode gamemode) { + return "map.gamemode.short." + gamemode.name(); + } + + public static String gamemodeLongName(MapDoc.Gamemode gamemode) { + return "map.gamemode.long." + gamemode.name(); + } + + public String legacyList(CommandSender viewer, String format, String elementFormat, Collection elements) { + switch(elements.size()) { + case 0: return ""; + case 1: return elementFormat + elements.iterator().next(); + case 2: + Iterator pair = elements.iterator(); + return t(format, "misc.list.pair", viewer, elementFormat + pair.next(), elementFormat + pair.next()); + default: + Iterator iter = elements.iterator(); + String a = t(format, "misc.list.start", viewer, elementFormat + iter.next(), elementFormat + iter.next()); + String b = elementFormat + iter.next(); + while(iter.hasNext()) { + a = t(format, "misc.list.middle", viewer, a, b); + b = elementFormat + iter.next(); + } + return t(format, "misc.list.end", viewer, a, b); + } + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/logging/MapdevLogger.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/logging/MapdevLogger.java new file mode 100644 index 0000000..fbb32b9 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/logging/MapdevLogger.java @@ -0,0 +1,37 @@ +package tc.oc.commons.bukkit.logging; + +import java.util.Optional; +import java.util.logging.Logger; +import javax.inject.Inject; +import javax.inject.Singleton; + +import net.kencochrane.raven.dsn.Dsn; +import tc.oc.api.util.Permissions; +import tc.oc.commons.core.logging.Loggers; +import tc.oc.minecraft.logging.BetterRaven; + +/** + * A logger that shows messages to map developers in-game, + * and forwards to the mapdev Sentry. + * + * This logger can be injected and used in any place where + * it might be useful for mapdevs to know about an error. + */ +@Singleton +public class MapdevLogger extends Logger { + @Inject MapdevLogger(Loggers loggers, MapdevSentryConfiguration sentryConfig, Optional raven) { + super(loggers.defaultLogger().getName() + ".maps", null); + + setParent(loggers.defaultLogger()); + setUseParentHandlers(false); + addHandler(new ChatLogHandler(Permissions.MAPERRORS)); + + if(sentryConfig.enabled() && raven.isPresent()) { + // If a map DSN is configured, create a seperate Raven for the map logger + final Dsn dsn = sentryConfig.dsn(); + loggers.defaultLogger().info("Sending mapdev errors to Sentry at " + dsn); + final BetterRaven mapRaven = raven.get().clone(dsn); + mapRaven.listen(this); + } + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/logging/MapdevSentryConfiguration.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/logging/MapdevSentryConfiguration.java new file mode 100644 index 0000000..7d9e070 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/logging/MapdevSentryConfiguration.java @@ -0,0 +1,24 @@ +package tc.oc.commons.bukkit.logging; + +import javax.inject.Inject; + +import net.kencochrane.raven.dsn.Dsn; +import org.bukkit.configuration.Configuration; +import org.bukkit.configuration.ConfigurationSection; + +public class MapdevSentryConfiguration { + + private final ConfigurationSection config; + + @Inject MapdevSentryConfiguration(Configuration config) { + this.config = config.getSection("mapdev-sentry"); + } + + public boolean enabled() { + return config.getBoolean("enabled", false); + } + + public Dsn dsn() { + return new Dsn(config.getString("dsn", null)); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/markup/MarkupParser.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/markup/MarkupParser.java new file mode 100644 index 0000000..2bfdf1c --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/markup/MarkupParser.java @@ -0,0 +1,158 @@ +package tc.oc.commons.bukkit.markup; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import javax.inject.Inject; + +import net.md_5.bungee.api.ChatColor; +import net.md_5.bungee.api.chat.BaseComponent; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import tc.oc.commons.bukkit.chat.LinkComponent; +import tc.oc.commons.bukkit.chat.Links; +import tc.oc.commons.bukkit.chat.UserURI; +import tc.oc.commons.bukkit.chat.Renderable; +import tc.oc.commons.core.chat.Component; +import tc.oc.commons.core.chat.Components; +import tc.oc.parse.MissingException; +import tc.oc.parse.ParseException; +import tc.oc.parse.ValueException; +import tc.oc.parse.xml.XML; +import tc.oc.parse.xml.ElementParser; +import tc.oc.parse.xml.NodeParser; +import tc.oc.parse.xml.UnrecognizedNodeException; + +/** + * Parses an XML dialect similar to HTML into {@link BaseComponent}s + * + * The following elements are supported: + * + * italic + * bold + * emphasis (gold colored) + * link + * + * The following attributes work on ANY element: + * + * color="..." + * bold="true/false" + * italic="true/false" + * underline="true/false" + * + * The anchor element has an extra attribute "type" that changes how + * the "href" attribute is interpreted: + * + * url Standard URL (default) + * home Path relative to network website + * user Path relative to user profile on website + * + * An anchor element with no content will display a compact form of the URL. + * + * The {@link #parse(Node)} and {@link #parse(Element)} methods expect a recognized node, + * whereas {@link #content(Element, List, ChatColor...)} will accept any {@link Element}, + * parsing its attributes and content. + */ +public class MarkupParser implements NodeParser, ElementParser { + + private final NodeParser colorParser; + private final NodeParser booleanParser; + + @Inject private MarkupParser(NodeParser colorParser, NodeParser booleanParser) { + this.colorParser = colorParser; + this.booleanParser = booleanParser; + } + + @Override + public BaseComponent parse(Node node) { + switch(node.getNodeType()) { + case Node.TEXT_NODE: + // We use {blank} to force Crowdin to include things in strings that it would otherwise exclude, + // such as empty tags at the beginning/end of the string e.g. + // + // Click here {blank} + // + // The {blank} forces the to be part of the string, and allows translators to move it around. + final String text = node.getNodeValue(); + return "{blank}".equals(text) ? Components.blank() + : new Component(node.getNodeValue()); + case Node.ELEMENT_NODE: + return parse((Element) node); + default: + return Components.blank(); + } + } + + @Override + public BaseComponent parse(Element el) throws ParseException { + // Crowdin only allows a few hard-coded tag names to be part of strings, + // so those are the only names we can use here. Any other tags will + // split the text into multiple strings. We use custom attributes to + // overload the meaning of these few tags. + switch(el.getTagName()) { + case "i": return content(el, ChatColor.ITALIC); + case "b": return content(el, ChatColor.BOLD); + case "em": return content(el, ChatColor.GOLD); + case "a": return anchor(el); + } + throw new UnrecognizedNodeException(el); + } + + BaseComponent anchor(Element el) { + final Optional href = XML.attrValue(el, "href"); + final Optional content = nonEmptyContent(el); + final String type = XML.attrValue(el, "type").orElse("url"); + final Renderable uri; + + try { + switch(type) { + case "user": + uri = new UserURI(href.orElse("")); + break; + + case "home": + uri = Renderable.of(Links.homeUri(href.orElse("/"))); + break; + + case "url": + uri = Renderable.of(new URI(href.orElseThrow(() -> new MissingException("attribute", "href")))); + break; + + default: + throw new ValueException("Unknown anchor type '" + type + "'"); + } + } catch(URISyntaxException e) { + throw new ValueException(e.getMessage()); + } + + return new LinkComponent(uri, content); + } + + public BaseComponent content(Element el, ChatColor... formats) { + return content(el, children(el).collect(Collectors.toList()), formats); + } + + public Optional nonEmptyContent(Element el, ChatColor... formats) { + final List children = children(el).collect(Collectors.toList()); + return children.isEmpty() ? Optional.empty() + : Optional.of(content(el, children, formats)); + } + + private BaseComponent content(Element el, List children, ChatColor... formats) { + final Component c = new Component(children, formats); + XML.attr(el, "bold").map(booleanParser::parse).ifPresent(c::bold); + XML.attr(el, "italic").map(booleanParser::parse).ifPresent(c::italic); + XML.attr(el, "underline").map(booleanParser::parse).ifPresent(c::underlined); + XML.attr(el, "color").map(colorParser::parse).ifPresent(c::add); + return c; + } + + private Stream children(Element el) { + return XML.childNodes(el) + .map(this::parse) + .filter(node -> !Components.blank().equals(node)); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/nick/ConsoleIdentity.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/nick/ConsoleIdentity.java new file mode 100644 index 0000000..880cddb --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/nick/ConsoleIdentity.java @@ -0,0 +1,117 @@ +package tc.oc.commons.bukkit.nick; + +import javax.annotation.Nullable; +import javax.inject.Singleton; + +import org.bukkit.Bukkit; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import tc.oc.api.docs.PlayerId; +import tc.oc.api.docs.UserId; + +@Singleton +public class ConsoleIdentity implements Identity { + + private static final String NAME = "Console"; + + @Override + public PlayerId getPlayerId() { + return new PlayerId() { + @Override + public String username() { + return NAME; + } + + @Override + public String _id() { + throw new UnsupportedOperationException(); + } + + @Override + public String player_id() { + throw new UnsupportedOperationException(); + } + }; + } + + @Override + public @Nullable String getNickname() { + return null; + } + + @Override + public String getRealName() { + return NAME; + } + + @Override + public String getPublicName() { + return NAME; + } + + @Override + public @Nullable Player getPlayer() { + return null; + } + + @Override + public boolean belongsTo(CommandSender sender) { + return sender == Bukkit.getConsoleSender(); + } + + @Override + public boolean isCurrent() { + return true; + } + + @Override + public String getName(CommandSender viewer) { + return NAME; + } + + @Override + public boolean isOnline(CommandSender viewer) { + return true; + } + + + @Override + public @Nullable Player getPlayer(CommandSender viewer) { + return null; + } + + @Override + public boolean isDead(CommandSender viewer) { + return false; + } + + @Override + public boolean isFriend(CommandSender viewer) { + return false; + } + + @Override + public Familiarity familiarity(CommandSender viewer) { + return Familiarity.ANONYMOUS; + } + + @Override + public boolean isDisguised(CommandSender viewer) { + return false; + } + + @Override + public boolean isRevealed(CommandSender viewer) { + return true; + } + + @Override + public boolean belongsTo(UserId userId, CommandSender viewer) { + return false; + } + + @Override + public boolean isSamePerson(Identity identity, CommandSender viewer) { + return identity instanceof ConsoleIdentity; + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/nick/Familiarity.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/nick/Familiarity.java new file mode 100644 index 0000000..eeecfe6 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/nick/Familiarity.java @@ -0,0 +1,15 @@ +package tc.oc.commons.bukkit.nick; + +import tc.oc.commons.core.util.Orderable; + +/** + * Level of familiarity between two players. Comparisons are useful e.g. + * + * identity.familiarity(viewer).noLessThan(Familiarity.FRIEND) + */ +public enum Familiarity implements Orderable { + ANONYMOUS, // Don't know who they are + PERSON, // Know their name, never met them + FRIEND, // On friend list + SELF // Same person +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/nick/Identity.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/nick/Identity.java new file mode 100644 index 0000000..ab31210 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/nick/Identity.java @@ -0,0 +1,107 @@ +package tc.oc.commons.bukkit.nick; + +import javax.annotation.Nullable; + +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import tc.oc.api.docs.PlayerId; +import tc.oc.api.docs.UserId; + +/** + * Captures aspects of a player's state that affect how they + * are identified to other players. A player assumes a new + * Identity whenever they change their nickname, or their + * visibility (when we have /vanish). Two Identities are + * equal if and only if they appear to be the same player + * to all possible viewers. + * + * It should never be assumed that an {@link Identity} is the + * player's *current* identity. The value of representing + * identities with a concrete object is that they can be + * stored and displayed even after the player who they belong + * to has assumed a different identity. + * + * An {@link IdentityProvider} is used to create identities, + * or to get the current identity of online players. + */ +public interface Identity { + + // Canonical properties + + /** + * The ID of the player who used this identity + */ + PlayerId getPlayerId(); + + /** + * The nickname used for this identity, or null if the player's real name is used + */ + @Nullable String getNickname(); + + + // Derived properties + + /** + * The (real) name of the player who used this identity + */ + String getRealName(); + + String getPublicName(); + + @Nullable Player getPlayer(); + + /** + * Does this identity belong to the given sender? + */ + boolean belongsTo(CommandSender sender); + + /** + * Is the owner of this identity currently online and using this identity? + */ + boolean isCurrent(); + + // Viewer-relative properties + + /** + * The name of this identity as seen by the given viewer + */ + String getName(CommandSender viewer); + + /** + * The CURRENT online state of this identity as seen by the given viewer + * (NOT the state at the time the identity was created) + */ + boolean isOnline(CommandSender viewer); + + @Nullable Player getPlayer(CommandSender viewer); + + /** + * The CURRENT living/dead state of this identity as seen by the given viewer + * (NOT the state at the time the identity was created) + */ + boolean isDead(CommandSender viewer); + + /** + * Is this identity friends with the given viewer, and the viewer is allowed to know this? + */ + boolean isFriend(CommandSender viewer); + + /** + * How is this identity known to the given viewer? + */ + Familiarity familiarity(CommandSender viewer); + + /** + * Is this identity disguised for the given viewer? + */ + boolean isDisguised(CommandSender viewer); + + /** + * Should the true owner of this identity be revealed to the given viewer? + */ + boolean isRevealed(CommandSender viewer); + + boolean belongsTo(UserId userId, CommandSender viewer); + + boolean isSamePerson(Identity identity, CommandSender viewer); +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/nick/IdentityImpl.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/nick/IdentityImpl.java new file mode 100644 index 0000000..482088c --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/nick/IdentityImpl.java @@ -0,0 +1,160 @@ +package tc.oc.commons.bukkit.nick; + +import java.util.Objects; +import javax.annotation.Nullable; + +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import tc.oc.api.bukkit.friends.OnlineFriends; +import tc.oc.api.bukkit.users.OnlinePlayers; +import tc.oc.api.docs.PlayerId; +import tc.oc.api.docs.UserId; +import tc.oc.commons.bukkit.util.PlayerStates; + +import static com.google.common.base.Preconditions.checkNotNull; + +/** + * Functionality common to real and nicked identities + */ +public class IdentityImpl implements Identity { + + protected final OnlinePlayers onlinePlayers; + protected final OnlineFriends friendMap; + protected final IdentityProvider identityProvider; + protected final PlayerStates playerStates; + protected final PlayerId playerId; + + private final @Nullable String nickname; + + IdentityImpl(OnlinePlayers onlinePlayers, OnlineFriends friendMap, PlayerStates playerStates, IdentityProvider identityProvider, PlayerId playerId, @Nullable String nickname) { + this.onlinePlayers = onlinePlayers; + this.friendMap = friendMap; + this.playerStates = playerStates; + this.identityProvider = identityProvider; + + this.playerId = checkNotNull(playerId); + this.nickname = nickname == null || nickname.equalsIgnoreCase(playerId.username()) ? null : nickname; + } + + @Override + public PlayerId getPlayerId() { + return playerId; + } + + public @Nullable Player getPlayer() { + return onlinePlayers.find(getPlayerId()); + } + + @Override + public String getRealName() { + return playerId.username(); + } + + @Override + public @Nullable String getNickname() { + return nickname; + } + + @Override + public String getPublicName() { + return getNickname() != null ? getNickname() : getRealName(); + } + + @Override + public String getName(CommandSender viewer) { + return isRevealed(viewer) ? getRealName() : getNickname(); + } + + @Override + public boolean isCurrent() { + final Player player = getPlayer(); + return player != null && equals(identityProvider.currentIdentity(player)); + } + + @Override + public boolean isDead(CommandSender viewer) { + if(!isOnline(viewer)) return false; + Player player = onlinePlayers.find(playerId); + return player != null && playerStates.isDead(player); + } + + @Override + public boolean isFriend(CommandSender viewer) { + return friendMap.areFriends(viewer, playerId); + } + + @Override + public boolean belongsTo(CommandSender sender) { + return sender.getName().equals(getPlayerId().username()); + } + + @Override + public Familiarity familiarity(CommandSender viewer) { + return belongsTo(viewer) ? Familiarity.SELF + : isFriend(viewer) ? Familiarity.FRIEND + : Familiarity.PERSON; + } + + @Override + public boolean isRevealed(CommandSender viewer) { + return getNickname() == null || identityProvider.reveal(viewer, getPlayerId()); + } + + @Override + public boolean isDisguised(CommandSender viewer) { + return !isRevealed(viewer); + } + + @Override + public boolean isOnline(CommandSender viewer) { + return getPlayer(viewer) != null; + } + + /** + * Get the online {@link Player} for this identity, if they are online, + * and the given viewer is allowed to know this. That is true if this + * is the player's current identity, or if both this and their current + * identity can be revealed to the viewer. + */ + @Override + public @Nullable Player getPlayer(CommandSender viewer) { + final Player player = getPlayer(); + if(player == null) return null; + final Identity current = identityProvider.currentIdentity(player); + return current.equals(this) || (isRevealed(viewer) && current.isRevealed(viewer)) ? player : null; + } + + @Override + public boolean belongsTo(UserId userId, CommandSender viewer) { + return playerId.equals(userId) && isRevealed(viewer); + } + + @Override + public boolean isSamePerson(Identity identity, CommandSender viewer) { + if(isRevealed(viewer) && identity.isRevealed(viewer)) { + return playerId.equals(identity.getPlayerId()); + } + + if(isDisguised(viewer) && identity.isDisguised(viewer)) { + return getNickname().equals(identity.getNickname()); + } + + return false; + } + + @Override + public boolean equals(Object o) { + if(this == o) + return true; + if(!(o instanceof Identity)) + return false; + Identity identity = (Identity) o; + return Objects.equals(getPlayerId(), identity.getPlayerId()) && + Objects.equals(getNickname(), identity.getNickname()); + } + + @Override + public int hashCode() { + return Objects.hash(getPlayerId(), getNickname()); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/nick/IdentityProvider.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/nick/IdentityProvider.java new file mode 100644 index 0000000..3d8590b --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/nick/IdentityProvider.java @@ -0,0 +1,45 @@ +package tc.oc.commons.bukkit.nick; + +import javax.annotation.Nullable; + +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import tc.oc.api.docs.PlayerId; +import tc.oc.api.docs.Session; +import tc.oc.api.docs.UserId; +import tc.oc.api.users.UserSearchResponse; + +public interface IdentityProvider { + + Identity createIdentity(PlayerId playerId, @Nullable String nickname); + + default Identity createIdentity(PlayerId playerId) { + return createIdentity(playerId, null); + } + + Identity createIdentity(Player player, @Nullable String nickname); + + Identity createIdentity(CommandSender player); + + Identity createIdentity(Session session); + + Identity createIdentity(UserSearchResponse response); + + Identity currentIdentity(CommandSender player); + + Identity currentIdentity(PlayerId playerId); + + default Identity currentOrConsoleIdentity(@Nullable PlayerId playerId) { + return playerId == null ? consoleIdentity() : currentIdentity(playerId); + } + + Identity consoleIdentity(); + + @Nullable Identity onlineIdentity(String name); + + void changeIdentity(Player player, @Nullable String nickname); + + boolean revealAll(CommandSender viewer); + + boolean reveal(CommandSender viewer, UserId userId); +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/nick/IdentityProviderImpl.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/nick/IdentityProviderImpl.java new file mode 100644 index 0000000..2748b22 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/nick/IdentityProviderImpl.java @@ -0,0 +1,203 @@ +package tc.oc.commons.bukkit.nick; + +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import javax.annotation.Nullable; +import javax.inject.Inject; +import javax.inject.Singleton; + +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.bukkit.event.EventBus; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.AsyncPlayerPreLoginEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import tc.oc.api.bukkit.friends.OnlineFriends; +import tc.oc.api.bukkit.users.BukkitUserStore; +import tc.oc.api.bukkit.users.OnlinePlayers; +import tc.oc.api.docs.PlayerId; +import tc.oc.api.docs.Session; +import tc.oc.api.docs.UserId; +import tc.oc.minecraft.scheduler.SyncExecutor; +import tc.oc.api.users.UserSearchResponse; +import tc.oc.commons.bukkit.event.UserLoginEvent; +import tc.oc.commons.bukkit.util.PlayerStates; +import tc.oc.commons.core.plugin.PluginFacet; + +@Singleton +public class IdentityProviderImpl implements IdentityProvider, Listener, PluginFacet { + + private static final String REVEAL_ALL_PERMISSION = "nick.see-through-all"; + + private final BukkitUserStore userStore; + private final EventBus eventBus; + private final OnlinePlayers onlinePlayers; + private final PlayerStates playerStates; + private final OnlineFriends friendMap; + private final SyncExecutor syncExecutor; + private final ConsoleIdentity consoleIdentity; + + private final Map identities = new HashMap<>(); + private final Map nicknames = new HashMap<>(); + + @Inject + IdentityProviderImpl(BukkitUserStore userStore, EventBus eventBus, OnlinePlayers onlinePlayers, PlayerStates playerStates, OnlineFriends friendMap, SyncExecutor syncExecutor, ConsoleIdentity consoleIdentity) { + this.userStore = userStore; + this.eventBus = eventBus; + this.onlinePlayers = onlinePlayers; + this.playerStates = playerStates; + this.friendMap = friendMap; + this.syncExecutor = syncExecutor; + this.consoleIdentity = consoleIdentity; + } + + @Override + public boolean revealAll(CommandSender viewer) { + return viewer.hasPermission(REVEAL_ALL_PERMISSION); + } + + @Override + public boolean reveal(CommandSender viewer, UserId userId) { + return revealAll(viewer) || friendMap.areFriends(viewer, userId); + } + + @Override + public Identity createIdentity(PlayerId playerId, @Nullable String nickname) { + return new IdentityImpl(onlinePlayers, friendMap, playerStates, this, playerId, nickname); + } + + @Override + public Identity createIdentity(Player player, @Nullable String nickname) { + return createIdentity(userStore.getUser(player), nickname); + } + + @Override + public Identity createIdentity(CommandSender player) { + return player instanceof Player ? createIdentity((Player) player, null) : consoleIdentity(); + } + + @Override + public Identity consoleIdentity() { + return consoleIdentity; + } + + @Override + public Identity createIdentity(Session session) { + return createIdentity(session.user(), session.nickname()); + } + + @Override + public Identity createIdentity(UserSearchResponse response) { + if(response.last_session != null) { + return createIdentity(response.user, response.last_session.nickname()); + } else { + return createIdentity(response.user, null); + } + } + + @Override + public Identity currentIdentity(CommandSender player) { + if(player instanceof Player) { + return currentIdentity(userStore.getUser((Player) player), (Player) player); + } else { + return consoleIdentity; + } + } + + @Override + public Identity currentIdentity(PlayerId playerId) { + return currentIdentity(playerId, onlinePlayers.find(playerId)); + } + + private Identity currentIdentity(PlayerId playerId, @Nullable Player player) { + Identity identity = identities.get(player); + if(identity == null) { + identity = createIdentity(playerId, null); + if(player != null && player.willBeOnline()) { + identities.put(player, identity); + } + } + return identity; + } + + @Override + public @Nullable Identity onlineIdentity(String name) { + Player player = nicknames.get(name); + if(player == null) player = onlinePlayers.find(name); + return player == null ? null : currentIdentity(player); + } + + @Override + public void changeIdentity(Player player, @Nullable String nickname) { + final Identity oldIdentity = currentIdentity(player); + if(Objects.equals(oldIdentity.getNickname(), nickname)) return; + + applyNickname(player, oldIdentity.getNickname(), nickname); + eventBus.callEvent(new PlayerIdentityChangeEvent(player, oldIdentity, currentIdentity(player))); + } + + protected void validateNickname(@Nullable String nickname) { + if(nickname != null && onlinePlayers.find(nickname) != null) { + throw new IllegalArgumentException("Nickname '" + nickname + "' is the name of a real online player"); + } + } + + protected void applyNickname(Player player, @Nullable String oldNickname, @Nullable String newNickname) { + validateNickname(newNickname); + + if(oldNickname != null) { + nicknames.remove(oldNickname); + } + + final Identity identity = createIdentity(userStore.getUser(player), newNickname); + if(player.willBeOnline()) { + identities.put(player, identity); + + if(newNickname != null) { + nicknames.put(newNickname, player); + } + } + } + + /** + * If a player has a nickname set in their user document on login, apply it. + * This needs to run before {@link PlayerAppearanceListener#refreshNamesOnLogin} + */ + @EventHandler(priority = EventPriority.LOW, ignoreCancelled = true) + public void applyNicknameOnLogin(UserLoginEvent event) { + if(event.getUser().nickname() != null) { + applyNickname(event.getPlayer(), null, event.getUser().nickname()); + } + } + + /** + * Clean up after quitting players + */ + @EventHandler(priority = EventPriority.MONITOR) + public void deactivateNickOnQuit(PlayerQuitEvent event) { + final Identity identity = identities.remove(event.getPlayer()); + if(identity != null && identity.getNickname() != null) { + nicknames.remove(identity.getNickname()); + } + } + + /** + * Clear any nickname that collides with the real name of a player logging in. + * This ensures that usernames + nicknames together contain no duplicates. + * The user who's nickname was cleared is not notified of this, but this + * should be an extremely rare situation, so it's not a big problem. + */ + @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = false) + public void clearConflictingNicks(AsyncPlayerPreLoginEvent event) { + final String name = event.getName(); + syncExecutor.execute(() -> { + final Player player = nicknames.get(name); + if(player != null) { + changeIdentity(player, null); + } + }); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/nick/NicknameCommands.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/nick/NicknameCommands.java new file mode 100644 index 0000000..7a44240 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/nick/NicknameCommands.java @@ -0,0 +1,355 @@ +package tc.oc.commons.bukkit.nick; + +import java.util.Objects; +import java.util.stream.Stream; +import javax.annotation.Nullable; +import javax.inject.Inject; +import javax.inject.Singleton; + +import com.google.common.base.Function; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.sk89q.minecraft.util.commands.Command; +import com.sk89q.minecraft.util.commands.CommandContext; +import com.sk89q.minecraft.util.commands.CommandException; +import com.sk89q.minecraft.util.commands.CommandPermissions; +import net.md_5.bungee.api.ChatColor; +import net.md_5.bungee.api.chat.BaseComponent; +import net.md_5.bungee.api.chat.TranslatableComponent; +import org.bukkit.Bukkit; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.permissions.Permission; +import org.bukkit.permissions.PermissionAttachment; +import org.bukkit.permissions.PermissionDefault; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.PluginManager; +import tc.oc.api.bukkit.users.OnlinePlayers; +import tc.oc.api.docs.PlayerId; +import tc.oc.api.docs.User; +import tc.oc.api.docs.virtual.UserDoc; +import tc.oc.api.exceptions.UnprocessableEntity; +import tc.oc.minecraft.scheduler.SyncExecutor; +import tc.oc.api.users.UserService; +import tc.oc.commons.bukkit.chat.Audiences; +import tc.oc.commons.bukkit.chat.NameStyle; +import tc.oc.commons.bukkit.chat.PlayerComponent; +import tc.oc.commons.bukkit.chat.WarningComponent; +import tc.oc.commons.bukkit.commands.CommandUtils; +import tc.oc.commons.bukkit.commands.UserFinder; +import tc.oc.commons.bukkit.event.UserLoginEvent; +import tc.oc.commons.bukkit.localization.Translations; +import tc.oc.commons.core.chat.Audience; +import tc.oc.commons.core.chat.Component; +import tc.oc.commons.core.commands.CommandFutureCallback; +import tc.oc.commons.core.commands.Commands; +import tc.oc.commons.core.commands.ComponentCommandException; + +@Singleton +public class NicknameCommands implements Listener, Commands { + + public static final String PERMISSION = "setting.nick"; + public static final String PERMISSION_SET = PERMISSION + ".set"; + public static final String PERMISSION_GET = PERMISSION + ".get"; + public static final String PERMISSION_IMMEDIATE = PERMISSION + ".immediate"; + public static final String PERMISSION_ANY = PERMISSION + ".any"; + public static final String PERMISSION_ANY_SET = PERMISSION_ANY + ".set"; + public static final String PERMISSION_ANY_GET = PERMISSION_ANY + ".get"; + + private final NicknameConfiguration config; + private final SyncExecutor syncExecutor; + private final UserService userService; + private final Audiences audiences; + private final IdentityProvider identities; + private final OnlinePlayers onlinePlayers; + private final UserFinder userFinder; + private final PluginManager pluginManager; + private final Plugin plugin; + + @Inject NicknameCommands(NicknameConfiguration config, + SyncExecutor syncExecutor, + UserService userService, + Audiences audiences, + IdentityProvider identities, + OnlinePlayers onlinePlayers, + UserFinder userFinder, + PluginManager pluginManager, + Plugin plugin) { + this.config = config; + this.syncExecutor = syncExecutor; + this.userService = userService; + this.audiences = audiences; + this.identities = identities; + this.onlinePlayers = onlinePlayers; + this.userFinder = userFinder; + this.pluginManager = pluginManager; + this.plugin = plugin; + } + + @Override + public void enable() { + final PermissionAttachment attachment = Bukkit.getConsoleSender().addAttachment(plugin); + Stream.of( + PERMISSION, + PERMISSION_GET, + PERMISSION_SET, + PERMISSION_ANY, + PERMISSION_ANY_GET, + PERMISSION_ANY_SET, + PERMISSION_IMMEDIATE + ).forEach(name -> { + final Permission permission = new Permission(name, PermissionDefault.FALSE); + pluginManager.addPermission(permission); + attachment.setPermission(permission, true); + }); + } + + private static boolean isSelf(CommandSender sender, @Nullable String username) { + return username == null || username.equals(sender.getName()); + } + + private void assertWritePerms(CommandSender sender, boolean self, boolean immediate) throws CommandException { + if(self) { + CommandUtils.assertPermission(sender, PERMISSION_SET); + } else { + CommandUtils.assertPermission(sender, PERMISSION_ANY_SET); + } + + if(immediate) { + CommandUtils.assertPermission(sender, PERMISSION_IMMEDIATE); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void sendNickReminderOnLogin(UserLoginEvent event) { + if(event.getUser().nickname() != null) { + if(event.getPlayer().hasPermission(PERMISSION_SET)) { + audiences.get(event.getPlayer()).sendMessage(new TranslatableComponent( + "nick.joinReminder", + new Component("/nick", ChatColor.GOLD), + new Component("/nick clear", ChatColor.GOLD) + )); + } else { + set(event.getUser(), null, true); + } + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void sendNickChangeMessage(PlayerIdentityChangeEvent event) { + if(event.getNewIdentity().getNickname() == null) { + audiences.get(event.getPlayer()).sendMessage(new TranslatableComponent("command.nick.clearSelf.immediate")); + } else { + audiences.get(event.getPlayer()).sendMessage(new TranslatableComponent("command.nick.setSelf.immediate", highlight(event.getNewIdentity().getNickname()))); + } + } + + @Command(aliases = {"nick" }, + usage = "list | show [player] | set [player] | clear [player] | [player]", + desc = "Show, set, or clear a nickname for yourself or another player. " + + "Changes will take effect the next time the player " + + "connects to the server. The -i option makes the change " + + "visible immediately.", + flags = "i", + min = 0, + max = 3) + @CommandPermissions(PERMISSION) + public void nick(final CommandContext args, final CommandSender sender) throws CommandException { + if(!config.enabled()) { + throw new CommandException(Translations.get().t("command.nick.notEnabled", sender)); + } + + final boolean immediate = args.hasFlag('i'); + + if(args.argsLength() == 0) { + show(sender, null); + } else { + final String arg = args.getString(0); + switch(arg) { + case "list": + list(sender); + break; + + case "show": + show(sender, args.getString(1, null)); + break; + + case "set": + if(args.argsLength() < 2) CommandUtils.notEnoughArguments(sender); + set(sender, args.getString(1), args.getString(2, null), immediate); + break; + + case "clear": + set(sender, null, args.getString(1, null), immediate); + break; + + default: + set(sender, arg, args.getString(1, null), immediate); + break; + } + } + } + + public void list(final CommandSender sender) throws CommandException { + CommandUtils.assertPermission(sender, PERMISSION_ANY_GET); + + final Audience audience = audiences.get(sender); + boolean some = false; + + for(Player player : onlinePlayers.all()) { + final Identity identity = identities.currentIdentity(player); + if(identity.getNickname() != null) { + some = true; + audience.sendMessage(new PlayerComponent(identity, NameStyle.VERBOSE)); + } + } + + if(!some) { + audience.sendMessage(new TranslatableComponent("command.nick.noActiveNicks")); + } + } + + public void show(final CommandSender sender, final @Nullable String username) throws CommandException { + final boolean self = isSelf(sender, username); + final Audience audience = audiences.get(sender); + + syncExecutor.callback( + userFinder.findUser(sender, username, UserFinder.Scope.ALL, UserFinder.Default.SENDER), + CommandFutureCallback.onSuccess(sender, result -> { + final Identity identity = identities.currentIdentity(result.user); + + if(self || identity.isFriend(sender)) { + CommandUtils.assertPermission(sender, PERMISSION_GET); + } else { + CommandUtils.assertPermission(sender, PERMISSION_ANY_GET); + } + + final String currentNick = identity.getNickname(); + final String pendingNick = result.user.nickname(); + + final String who = self ? "Self" : "Other"; + final PlayerComponent name = self ? null : new PlayerComponent(identity, NameStyle.FANCY); + + TranslatableComponent message = null; + + if(currentNick != null) { + message = new TranslatableComponent("command.nick.set" + who + ".immediate", highlight(currentNick)); + } else if(pendingNick == null) { + message = new TranslatableComponent("command.nick.clear" + who + ".immediate"); + } + + if(message != null) { + if(name != null) message.addWith(name); + audience.sendMessage(message); + } + + if(!Objects.equals(currentNick, pendingNick)) { + if(pendingNick != null) { + message = new TranslatableComponent("command.nick.set" + who + ".queued", highlight(pendingNick)); + } else { + message = new TranslatableComponent("command.nick.clear" + who + ".queued"); + } + + if(name != null) message.addWith(name); + audience.sendMessage(message); + } + }) + ); + } + + private static Component highlight(String nickname) { + return new Component(nickname, ChatColor.AQUA); + } + + public @Nullable BaseComponent invalidReason(@Nullable String nickname) { + if(nickname == null) return null; + + if(!nickname.matches("^[A-Za-z0-9_]+$")) { + return new TranslatableComponent("command.nick.invalidCharacters"); + } + if(nickname.length() > 16) { + return new TranslatableComponent("command.nick.tooLong"); + } + if(nickname.length() < 4) { + return new TranslatableComponent("command.nick.tooShort"); + } + if(onlinePlayers.find(nickname) != null) { + return new TranslatableComponent("command.nick.nickTaken", highlight(nickname)); + } + return null; + } + + private void validate(@Nullable String nickname) throws CommandException { + final BaseComponent reason = invalidReason(nickname); + if(reason != null) { + throw new ComponentCommandException(reason); + } + } + + public void set(final CommandSender sender, final @Nullable String nickname, final @Nullable String username, final boolean immediate) throws CommandException { + final boolean self = isSelf(sender, username); + + // Don't need perms to clear your own nickname, only to set it + if(!(self && nickname == null)) { + assertWritePerms(sender, self, immediate); + } + + final Audience audience = audiences.get(sender); + + if(nickname != null) { + validate(nickname); + audience.sendMessage(new TranslatableComponent("command.nick.checkingNickname", highlight(nickname))); + } + + // TODO: a way to do this with only one API call instead of two + syncExecutor.callback( + userFinder.findUser(sender, username, UserFinder.Scope.ALL, UserFinder.Default.SENDER), + CommandFutureCallback.onSuccess(sender, response -> { + syncExecutor.callback( + set(response.user, nickname, immediate), + CommandFutureCallback.onSuccess(sender, user -> { + if(!(self && immediate)) { + final String key = "command.nick." + + (nickname == null ? "clear" : "set") + + (self ? "Self" : "Other") + + (immediate ? ".immediate" : ".queued"); + + final TranslatableComponent message = new TranslatableComponent(key); + if(nickname != null) message.addWith(highlight(nickname)); + if(!self) message.addWith(new PlayerComponent(identities.createIdentity(user, null), NameStyle.FANCY)); + + audience.sendMessage(message); + } + }).onFailure(UnprocessableEntity.class, ex -> { + // Assume any validation error is a username collision + audience.sendMessage(new WarningComponent("command.nick.nickTaken", highlight(nickname))); + }) + ); + }) + ); + } + + public ListenableFuture set(PlayerId playerId, @Nullable String nickname, boolean immediate) { + final BaseComponent reason = invalidReason(nickname); + if(reason != null) { + return Futures.immediateFailedFuture(new ComponentCommandException(reason)); + } + + return Futures.transform( + userService.update(playerId, (UserDoc.Nickname) () -> nickname), + (Function) user -> { + if(immediate) { + final Player player = onlinePlayers.find(user); + if(player != null) { + identities.changeIdentity(player, nickname); + } + } + return user; + }, + syncExecutor + ); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/nick/NicknameConfiguration.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/nick/NicknameConfiguration.java new file mode 100644 index 0000000..d329db4 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/nick/NicknameConfiguration.java @@ -0,0 +1,26 @@ +package tc.oc.commons.bukkit.nick; + +import javax.inject.Inject; + +import org.bukkit.configuration.Configuration; +import org.bukkit.configuration.ConfigurationSection; + +import static com.google.common.base.Preconditions.checkNotNull; + +public class NicknameConfiguration { + + private final ConfigurationSection config; + + @Inject NicknameConfiguration(Configuration config) { + this.config = checkNotNull(config.getConfigurationSection("nicks")); + } + + public boolean enabled() { + return config.getBoolean("enabled", false); + } + + public boolean overheadFlair() { + // This doesn't strike me as particularly nickname related + return config.getBoolean("overhead-flair", false); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/nick/PlayerAppearanceChanger.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/nick/PlayerAppearanceChanger.java new file mode 100644 index 0000000..25714e7 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/nick/PlayerAppearanceChanger.java @@ -0,0 +1,128 @@ +package tc.oc.commons.bukkit.nick; + +import javax.annotation.Nullable; +import javax.inject.Inject; +import javax.inject.Singleton; + +import org.bukkit.Skin; +import org.bukkit.entity.Player; +import org.bukkit.scoreboard.Scoreboard; +import org.bukkit.scoreboard.Team; +import tc.oc.commons.bukkit.chat.FlairRenderer; +import tc.oc.commons.bukkit.chat.FullNameRenderer; +import tc.oc.commons.bukkit.chat.NameStyle; +import tc.oc.commons.bukkit.chat.NameType; +import tc.oc.commons.core.scheduler.Scheduler; + +/** + * Manages the Bukkit aspects of a player's name and appearance + */ +@Singleton +public class PlayerAppearanceChanger { + + private static final NameType REAL_NAME_TYPE = new NameType(NameStyle.VERBOSE, true, true, false, false, false); + private static final NameType NICKNAME_TYPE = new NameType(NameStyle.VERBOSE, true, false, false, false, false); + + private final IdentityProvider identityProvider; + private final NicknameConfiguration config; + private final Scheduler scheduler; + private final FullNameRenderer nameRenderer; + private final UsernameRenderer usernameRenderer; + private final FlairRenderer flairRenderer; + + @Inject + PlayerAppearanceChanger(IdentityProvider identityProvider, NicknameConfiguration config, Scheduler scheduler, FullNameRenderer nameRenderer, UsernameRenderer usernameRenderer, FlairRenderer flairRenderer) { + this.identityProvider = identityProvider; + this.config = config; + this.scheduler = scheduler; + this.nameRenderer = nameRenderer; + this.usernameRenderer = usernameRenderer; + this.flairRenderer = flairRenderer; + } + + /** + * Refresh the given player's appearance for all viewers. + */ + public void refreshPlayer(Player player) { + refreshPlayer(player, identityProvider.currentIdentity(player)); + } + + /** + * Refresh the given player's appearance for all viewers, assuming the given identity is their current one. + * + * This is necessary if the player's identity changes, or if their current identity's name is invalidated. + */ + public void refreshPlayer(final Player player, final Identity identity) { + player.setDisplayName(nameRenderer.getLegacyName(identity, REAL_NAME_TYPE)); + + final String legacyNickname = renderLegacyNickname(identity); + for(Player viewer : player.getServer().getOnlinePlayers()) { + refreshFakeNameAndSkin(player, identity, legacyNickname, viewer); + } + + if(config.overheadFlair()) { + String prefix = usernameRenderer.getColor(identity, REAL_NAME_TYPE).toString(); + if(identity.getNickname() == null) { + prefix = flairRenderer.getLegacyName(identity, REAL_NAME_TYPE) + prefix; + } + setOverheadNamePrefix(player, prefix); + } + } + + /** + * Refresh the appearance of all players for the given viewer + */ + public void refreshViewer(final Player viewer) { + for(Player player : viewer.getServer().getOnlinePlayers()) { + final Identity identity = identityProvider.currentIdentity(player); + refreshFakeNameAndSkin(player, identity, renderLegacyNickname(identity), viewer); + } + } + + /** + * Release any resources being used to maintain the given player's appearance + */ + public void cleanupAfterPlayer(Player player) { + if(config.overheadFlair()) { + // Remove players from their "overhead flair team" on quit + final Team team = player.getServer().getScoreboardManager().getMainScoreboard().getPlayerTeam(player); + if(team != null) { + scheduler.debounceTask(() -> team.removePlayer(player)); + } + } + } + + /** + * Refresh the given player's fake appearance for the given viewer, assuming the given identity + */ + private void refreshFakeNameAndSkin(Player player, Identity identity, @Nullable String fakeDisplayName, Player viewer) { + if(identity.isRevealed(viewer)) { + player.setFakeNameAndSkin(viewer, null, null); + player.setFakeDisplayName(viewer, null); + } else { + player.setFakeNameAndSkin(viewer, identity.getNickname(), Skin.EMPTY); + player.setFakeDisplayName(viewer, fakeDisplayName); + } + } + + /** + * Sets a prefix for a player's overhead name by adding them to a scoreboard team. + * Don't use this if scoreboard teams are being used for any other purpose. + */ + private static void setOverheadNamePrefix(Player player, String prefix) { + final Scoreboard scoreboard = player.getServer().getScoreboardManager().getMainScoreboard(); + prefix = prefix.substring(0, Math.min(prefix.length(), 14)); + + Team team = scoreboard.getTeam(prefix); + if(team == null) { + team = scoreboard.registerNewTeam(prefix); + team.setPrefix(prefix); + team.setOption(Team.Option.COLLISION_RULE, Team.OptionStatus.NEVER); + } + team.addPlayer(player); + } + + private @Nullable String renderLegacyNickname(Identity identity) { + return identity.getNickname() == null ? null : nameRenderer.getLegacyName(identity, NICKNAME_TYPE); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/nick/PlayerAppearanceListener.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/nick/PlayerAppearanceListener.java new file mode 100644 index 0000000..17ed254 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/nick/PlayerAppearanceListener.java @@ -0,0 +1,50 @@ +package tc.oc.commons.bukkit.nick; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import org.bukkit.Server; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerLoginEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import tc.oc.commons.core.plugin.PluginFacet; + +/** + * Listens for events that require player names and/or appearances to be refreshed. + */ +@Singleton +public class PlayerAppearanceListener implements Listener, PluginFacet { + + private final PlayerAppearanceChanger playerAppearanceChanger; + + @Inject PlayerAppearanceListener(PlayerAppearanceChanger playerAppearanceChanger) { + this.playerAppearanceChanger = playerAppearanceChanger; + } + + @EventHandler(priority = EventPriority.LOW) + public void onIdentityChange(PlayerIdentityChangeEvent event) { + playerAppearanceChanger.refreshPlayer(event.getPlayer(), event.getNewIdentity()); + } + + /** + * When a player logs in, refresh their own appearance, and the appearance of all other players for them. + * This must run after {@link IdentityProviderImpl#applyNicknameOnLogin} + * + * Note: this needs to happen in {@link PlayerJoinEvent} rather than {@link PlayerLoginEvent}, + * because the latter fires before the player is added to {@link Server#getOnlinePlayers}, + * and the nickname packet filter in SportBukkit uses that list to lookup players from PacketPlayOutScoreboardTeam. + */ + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void refreshNamesOnLogin(PlayerJoinEvent event) { + playerAppearanceChanger.refreshPlayer(event.getPlayer()); + playerAppearanceChanger.refreshViewer(event.getPlayer()); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onQuit(PlayerQuitEvent event) { + playerAppearanceChanger.cleanupAfterPlayer(event.getPlayer()); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/nick/PlayerIdentityChangeEvent.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/nick/PlayerIdentityChangeEvent.java new file mode 100644 index 0000000..235ddcc --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/nick/PlayerIdentityChangeEvent.java @@ -0,0 +1,44 @@ +package tc.oc.commons.bukkit.nick; + +import org.bukkit.entity.Player; +import org.bukkit.event.HandlerList; +import org.bukkit.event.player.PlayerEvent; + +import static com.google.common.base.Preconditions.checkNotNull; + +/** + * Fired when a player changes/clears their nickname immediately during a session, + * i.e. with "/nick -i". This is NEVER fired on login or logout. + */ +public class PlayerIdentityChangeEvent extends PlayerEvent { + private final Identity oldIdentity; + private final Identity newIdentity; + + public PlayerIdentityChangeEvent(Player player, Identity oldIdentity, Identity newIdentity) { + super(player); + this.oldIdentity = checkNotNull(oldIdentity); + this.newIdentity = checkNotNull(newIdentity); + } + + public Identity getOldIdentity() { + return oldIdentity; + } + + public Identity getNewIdentity() { + return newIdentity; + } + + /** + * HandlerList stuff + */ + private static final HandlerList handlers = new HandlerList(); + + @Override + public HandlerList getHandlers() { + return handlers; + } + + public static HandlerList getHandlerList() { + return handlers; + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/nick/PlayerOrder.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/nick/PlayerOrder.java new file mode 100644 index 0000000..4814a1d --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/nick/PlayerOrder.java @@ -0,0 +1,14 @@ +package tc.oc.commons.bukkit.nick; + +import java.util.Comparator; +import java.util.function.Function; + +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +/** + * The order that {@link Player}s appear in the player list, and various other places. + */ +public interface PlayerOrder extends Comparator { + interface Factory extends Function {} +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/nick/PlayerOrderCache.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/nick/PlayerOrderCache.java new file mode 100644 index 0000000..5d5c96b --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/nick/PlayerOrderCache.java @@ -0,0 +1,90 @@ +package tc.oc.commons.bukkit.nick; + +import java.lang.ref.WeakReference; +import javax.inject.Inject; +import javax.inject.Singleton; + +import com.google.common.cache.LoadingCache; +import com.google.common.collect.ComparisonChain; +import com.google.common.eventbus.Subscribe; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.player.PlayerQuitEvent; +import tc.oc.api.minecraft.servers.LocalServerReconfigureEvent; +import tc.oc.api.bukkit.users.BukkitUserStore; +import tc.oc.api.minecraft.MinecraftService; +import tc.oc.commons.core.plugin.PluginFacet; +import tc.oc.commons.core.util.CacheUtils; + +@Singleton +public class PlayerOrderCache implements PlayerOrder.Factory, PluginFacet { + + private final LoadingCache prioritiesByPlayer; + private final LoadingCache comparatorsByViewer; + + @Inject PlayerOrderCache(IdentityProvider identityProvider, BukkitUserStore userStore, MinecraftService minecraftService) { + + prioritiesByPlayer = CacheUtils.newWeakKeyCache(player -> userStore + .getUser(player) + .minecraft_flair() + .stream() + .filter(flair -> minecraftService.getLocalServer().realms().contains(flair.realm)) + .map(flair -> flair.priority) + .min(Integer::compare) + .orElse(Integer.MAX_VALUE) + ); + + comparatorsByViewer = CacheUtils.newWeakKeyCache(strongViewer -> { + final WeakReference weakViewer = new WeakReference<>(strongViewer); + return (a, b) -> { + // Do not reference strongViewer in here + final CommandSender viewer = weakViewer.get(); + if(viewer == null) return 0; + + final Identity aIdentity = identityProvider.currentIdentity(a); + final Identity bIdentity = identityProvider.currentIdentity(b); + return ComparisonChain.start() + .compareTrueFirst(a == viewer, b == viewer) + .compareTrueFirst(aIdentity.isFriend(viewer), bIdentity.isFriend(viewer)) + .compare(priority(a, aIdentity, viewer), priority(b, bIdentity, viewer)) + .compare(aIdentity.getName(viewer), bIdentity.getName(viewer), String::compareToIgnoreCase) + .result(); + }; + }); + } + + private int priority(Player player, Identity identity, CommandSender viewer) { + if(identity.isDisguised(viewer)) return Integer.MAX_VALUE; + + final Integer priority = prioritiesByPlayer.getUnchecked(player); + if(!player.willBeOnline()) { + prioritiesByPlayer.invalidate(player); + } + return priority; + } + + @Override + public PlayerOrder apply(CommandSender viewer) { + final PlayerOrder order = comparatorsByViewer.getUnchecked(viewer); + if(viewer instanceof Player && !((Player) viewer).willBeOnline()) { + comparatorsByViewer.invalidate(viewer); + } + return order; + } + + @Subscribe + public void onReconfigure(LocalServerReconfigureEvent event) { + // Invalidate everything if local realms change + if(event.getOldConfig() != null && !event.getOldConfig().realms().equals(event.getNewConfig().realms())) { + prioritiesByPlayer.invalidateAll(); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + private void onQuit(PlayerQuitEvent event) { + prioritiesByPlayer.invalidate(event.getPlayer()); + comparatorsByViewer.invalidate(event.getPlayer()); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/nick/UsernameRenderer.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/nick/UsernameRenderer.java new file mode 100644 index 0000000..d1f5951 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/nick/UsernameRenderer.java @@ -0,0 +1,103 @@ +package tc.oc.commons.bukkit.nick; + +import javax.annotation.Nullable; +import javax.inject.Inject; +import javax.inject.Singleton; + +import net.md_5.bungee.api.ChatColor; +import net.md_5.bungee.api.chat.BaseComponent; +import net.md_5.bungee.api.chat.ClickEvent; +import net.md_5.bungee.api.chat.HoverEvent; +import net.md_5.bungee.api.chat.TranslatableComponent; +import tc.oc.commons.bukkit.chat.NameFlag; +import tc.oc.commons.bukkit.chat.PartialNameRenderer; +import tc.oc.commons.bukkit.chat.NameType; +import tc.oc.commons.core.chat.Component; + +@Singleton +public class UsernameRenderer implements PartialNameRenderer { + + public static final ChatColor OFFLINE_COLOR = ChatColor.DARK_AQUA; + public static final ChatColor ONLINE_COLOR = ChatColor.AQUA; + public static final ChatColor DEAD_COLOR = ChatColor.DARK_GRAY; + + @Inject protected UsernameRenderer() {} + + public String getTextName(Identity identity, NameType type) { + if(identity.getNickname() != null && !type.reveal) { + return identity.getNickname(); + } else { + return identity.getRealName(); + } + } + + public ChatColor getColor(Identity identity, NameType type) { + return type.dead && type.style.contains(NameFlag.DEATH) ? DEAD_COLOR : type.online ? ONLINE_COLOR : OFFLINE_COLOR; + } + + @Override + public String getLegacyName(Identity identity, NameType type) { + String name = getTextName(identity, type); + final String color = type.style.contains(NameFlag.COLOR) ? getColor(identity, type).toString() : ""; + String format = color; + + if(type.style.contains(NameFlag.SELF) && type.self && type.reveal) { + format += ChatColor.BOLD; + } + + if(type.style.contains(NameFlag.FRIEND) && type.friend && type.reveal) { + format += ChatColor.ITALIC; + } + + if(type.style.contains(NameFlag.DISGUISE) && identity.getNickname() != null && type.reveal) { + format += ChatColor.STRIKETHROUGH; + + if(type.style.contains(NameFlag.NICKNAME)) { + name += ChatColor.RESET + " " + color + ChatColor.ITALIC + identity.getNickname(); + } + } + + return format + name; + } + + @Override + public BaseComponent getComponentName(Identity identity, NameType type) { + Component rendered = new Component(getTextName(identity, type)); + + if(type.style.contains(NameFlag.SELF) && type.self && type.reveal) { + rendered.setBold(true); + } + + if(type.style.contains(NameFlag.FRIEND) && type.friend && type.reveal) { + rendered.setItalic(true); + } + + if(type.style.contains(NameFlag.DISGUISE) && identity.getNickname() != null && type.reveal) { + rendered.setStrikethrough(true); + + if(type.style.contains(NameFlag.NICKNAME)) { + rendered = new Component(rendered, new Component(" " + identity.getNickname(), ChatColor.ITALIC)); + } + } + + if(type.style.contains(NameFlag.COLOR)) { + rendered.setColor(getColor(identity, type)); + } + + if(type.style.contains(NameFlag.TELEPORT)) { + Component dupe = rendered.duplicate(); + rendered.clickEvent(makeRemoteTeleportClickEvent(identity, identity.getNickname() != null && !type.reveal)); + rendered.hoverEvent(HoverEvent.Action.SHOW_TEXT, new TranslatableComponent("tip.teleportTo", dupe)); + } + + return rendered; + } + + public ClickEvent makeRemoteTeleportClickEvent(@Nullable String traveler, String destination) { + return new ClickEvent(ClickEvent.Action.RUN_COMMAND, "/rtp " + (traveler == null ? "" : traveler + " ") + destination); + } + + public ClickEvent makeRemoteTeleportClickEvent(Identity destination, boolean useNick) { + return makeRemoteTeleportClickEvent(null, useNick ? destination.getNickname() : destination.getRealName()); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/punishment/PunishmentCommands.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/punishment/PunishmentCommands.java new file mode 100644 index 0000000..ab5399a --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/punishment/PunishmentCommands.java @@ -0,0 +1,231 @@ +package tc.oc.commons.bukkit.punishment; + +import java.time.Duration; +import java.util.List; +import javax.annotation.Nullable; +import javax.inject.Inject; +import javax.inject.Singleton; + +import com.google.common.collect.Collections2; +import com.sk89q.minecraft.util.commands.Command; +import com.sk89q.minecraft.util.commands.CommandContext; +import com.sk89q.minecraft.util.commands.CommandException; +import com.sk89q.minecraft.util.commands.CommandPermissionsException; +import net.md_5.bungee.api.chat.BaseComponent; +import net.md_5.bungee.api.chat.TranslatableComponent; +import org.bukkit.command.CommandSender; +import tc.oc.api.docs.Punishment; +import tc.oc.api.docs.User; +import tc.oc.api.model.QueryService; +import tc.oc.api.punishments.PunishmentSearchRequest; +import tc.oc.commons.bukkit.chat.Audiences; +import tc.oc.commons.bukkit.chat.Paginator; +import tc.oc.commons.bukkit.chat.PlayerComponent; +import tc.oc.commons.bukkit.chat.WarningComponent; +import tc.oc.commons.bukkit.commands.CommandUtils; +import tc.oc.commons.bukkit.commands.UserFinder; +import tc.oc.commons.bukkit.nick.IdentityProvider; +import tc.oc.commons.core.chat.Audience; +import tc.oc.commons.core.commands.Commands; +import tc.oc.commons.core.concurrent.Flexecutor; +import tc.oc.minecraft.scheduler.Sync; + +import static tc.oc.api.docs.virtual.PunishmentDoc.Type; +import static tc.oc.api.docs.virtual.PunishmentDoc.Type.*; +import static tc.oc.commons.bukkit.commands.UserFinder.Default.NULL; +import static tc.oc.commons.bukkit.commands.UserFinder.Default.SENDER; +import static tc.oc.commons.bukkit.punishment.PunishmentPermissions.fromFlag; +import static tc.oc.commons.bukkit.punishment.PunishmentPermissions.fromType; + +@Singleton +public class PunishmentCommands implements Commands { + + private final QueryService punishmentService; + private final PunishmentCreator punishmentCreator; + private final PunishmentFormatter punishmentFormatter; + private final PunishmentEnforcer punishmentEnforcer; + private final UserFinder userFinder; + private final IdentityProvider identityProvider; + private final Audiences audiences; + private final Flexecutor syncExecutor; + + @Inject PunishmentCommands(QueryService punishmentService, + PunishmentCreator punishmentCreator, + PunishmentFormatter punishmentFormatter, + PunishmentEnforcer punishmentEnforcer, + UserFinder userFinder, + IdentityProvider identityProvider, + Audiences audiences, + @Sync Flexecutor syncExecutor) { + + this.punishmentService = punishmentService; + this.punishmentCreator = punishmentCreator; + this.punishmentFormatter = punishmentFormatter; + this.punishmentEnforcer = punishmentEnforcer; + this.userFinder = userFinder; + this.identityProvider = identityProvider; + this.audiences = audiences; + this.syncExecutor = syncExecutor; + } + + public boolean flag(char flag, CommandContext args, CommandSender sender) throws CommandException { + if(args.hasFlag(flag)) { + if(sender.hasPermission(fromFlag(flag))) { + return true; + } throw new CommandPermissionsException(); + } + return false; + } + + public boolean permission(CommandSender sender, @Nullable Type type) throws CommandException { + if(!sender.hasPermission(fromType(type))) { + throw new CommandPermissionsException(); + } + return true; + } + + public void create(CommandContext args, CommandSender sender, @Nullable Type type, @Nullable Duration duration) throws CommandException { + final User punisher = userFinder.getLocalUser(sender); + final String reason = args.getRemainingString(duration == null ? 1 : 2); + final boolean auto = flag('a', args, sender); + final boolean silent = flag('s', args, sender); + final boolean offrecord = flag('o', args, sender); + if(permission(sender, type)) { + syncExecutor.callback( + userFinder.findUser(sender, args, 0), + response -> { + punishmentCreator.create( + punisher, + response.user, + reason, + type, + duration, + silent, + auto, + offrecord + ); + } + ); + } + } + + @Command( + aliases = { "w", "warn" }, + flags = "aso", + usage = " ", + desc = "Warn a player for a reason.", + min = 2 + ) + public void warn(CommandContext args, CommandSender sender) throws CommandException { + create(args, sender, WARN, null); + } + + @Command( + aliases = { "k", "kick" }, + flags = "aso", + usage = " ", + desc = "Kick a player for a reason.", + min = 2 + ) + public void kick(CommandContext args, CommandSender sender) throws CommandException { + create(args, sender, KICK, null); + } + + @Command( + aliases = { "tb", "tempban" }, + flags = "aso", + usage = " ", + desc = "Temporarily ban a player for a reason.", + min = 3 + ) + public void tempban(CommandContext args, CommandSender sender) throws CommandException { + create(args, sender, BAN, CommandUtils.getDuration(args, 1, Duration.ofDays(7))); + } + + @Command( + aliases = { "pb", "permaban" }, + flags = "aso", + usage = " ", + desc = "Permanently ban a player for a reason.", + min = 2 + ) + public void permaban(CommandContext args, CommandSender sender) throws CommandException { + create(args, sender, BAN, null); + } + + @Command( + aliases = { "p", "punish" }, + flags = "aso", + usage = " ", + desc = "Punish a player for a reason.", + min = 2 + ) + public void punish(CommandContext args, CommandSender sender) throws CommandException { + create(args, sender, null, null); // Website will handle choosing which punishment + } + + @Command( + aliases = { "rp", "repeatpunish" }, + usage = "[player]", + desc = "Show the last punishment you issued or repeat it for a different player", + min = 0, + max = 1 + ) + public void repeat(CommandContext args, CommandSender sender) throws CommandException { + final User punisher = userFinder.getLocalUser(sender); + final Audience audience = audiences.get(sender); + if(permission(sender, null)) { + syncExecutor.callback( + userFinder.findUser(sender, args, 0, NULL), + punished -> syncExecutor.callback( + punishmentService.find(PunishmentSearchRequest.punisher(punisher, 1)), + punishments -> punishments.documents().stream().findFirst().map(last -> () -> { + if (punished != null) { + punishmentCreator.repeat(last, punished.user); + } else { + audience.sendMessages(punishmentFormatter.format(last, false, false)); + } + }).orElse(() -> audience.sendMessage(new WarningComponent("punishment.noneIssued"))).run() + ) + ); + } + } + + @Command( + aliases = { "l", "lookup" }, + usage = "", + desc = "Lookup previous punishments for player.", + min = 0, + max = 1 + ) + public void lookup(CommandContext args, CommandSender sender) throws CommandException { + syncExecutor.callback( + userFinder.findUser(sender, args, 0, SENDER), + user -> { + syncExecutor.callback( + punishmentService.find(PunishmentSearchRequest.punished(user.user, true, null)), + punishments -> { + new Paginator() { + @Override + protected BaseComponent title() { + return new TranslatableComponent( + "punishment.lookup", + new PlayerComponent(identityProvider.createIdentity(user)) + ); + } + @Override + protected List multiEntry(Punishment entry, int index) { + return punishmentFormatter.format(entry, false, false); + } + }.display( + sender, + Collections2.filter(punishments.documents(), p -> punishmentEnforcer.viewable(sender, p, false)), + args.getInteger(1, 1) + ); + } + ); + } + ); + } + +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/punishment/PunishmentCreator.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/punishment/PunishmentCreator.java new file mode 100644 index 0000000..855e7a2 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/punishment/PunishmentCreator.java @@ -0,0 +1,74 @@ +package tc.oc.commons.bukkit.punishment; + +import java.time.Duration; +import java.time.Instant; +import javax.annotation.Nullable; +import javax.inject.Inject; +import javax.inject.Singleton; + +import com.google.common.util.concurrent.ListenableFuture; +import tc.oc.api.docs.PlayerId; +import tc.oc.api.docs.Punishment; +import tc.oc.api.docs.Server; +import tc.oc.api.docs.virtual.MatchDoc; +import tc.oc.api.docs.virtual.PunishmentDoc; +import tc.oc.api.model.IdFactory; +import tc.oc.api.model.ModelService; +import tc.oc.commons.bukkit.report.ReportConfiguration; + +@Singleton +public class PunishmentCreator { + + private final ReportConfiguration config; + private final ModelService punishmentService; + private final IdFactory idFactory; + private final Server localServer; + + @Inject PunishmentCreator(ReportConfiguration config, ModelService punishmentService, IdFactory idFactory, Server localServer) { + this.config = config; + this.punishmentService = punishmentService; + this.idFactory = idFactory; + this.localServer = localServer; + } + + public boolean offRecord() { + return !config.crossServer(); + } + + public ListenableFuture create(@Nullable PlayerId punisher, PlayerId punished, String reason, @Nullable PunishmentDoc.Type type, @Nullable Duration duration, boolean silent, boolean auto, boolean offrecord) { + final String id = idFactory.newId(); + final Instant time = Instant.now(); + final MatchDoc match = localServer.current_match(); + return punishmentService.update(new PunishmentDoc.Creation() { + public String punisher_id() { return punisher != null ? punisher._id() : null; } + public String punished_id() { return punished._id(); } + public String match_id() { return match != null ? match._id() : null; } + public String server_id() { return localServer._id(); } + public String family() { return localServer.family(); } + public String reason() { return reason; } + public PunishmentDoc.Type type() { return type; } + public Instant date() { return time; } + public Instant expire() { return duration != null ? date().plus(duration) : null; } + public boolean off_record() { return offRecord() || offrecord; } + public boolean debatable() { return true; } + public boolean silent() { return silent; } + public boolean automatic() { return auto; } + public boolean active() { return true; } + public String _id() { return id; } + }); + } + + public ListenableFuture repeat(Punishment base, PlayerId punished) { + return create( + base.punisher(), + punished, + base.reason(), + base.type(), + base.expire() == null ? null : Duration.between(base.date(), base.expire()), + base.silent(), + base.automatic(), + false // Since off the record punishments are destroyed, this can never be true + ); + } + +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/punishment/PunishmentEnforcer.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/punishment/PunishmentEnforcer.java new file mode 100644 index 0000000..32aba19 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/punishment/PunishmentEnforcer.java @@ -0,0 +1,165 @@ +package tc.oc.commons.bukkit.punishment; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import org.bukkit.Sound; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import tc.oc.api.bukkit.users.OnlinePlayers; +import tc.oc.api.docs.Punishment; +import tc.oc.api.docs.Server; +import tc.oc.api.docs.virtual.PunishmentDoc; +import tc.oc.api.docs.virtual.ServerDoc; +import tc.oc.api.message.MessageListener; +import tc.oc.api.message.MessageQueue; +import tc.oc.api.message.types.ModelUpdate; +import tc.oc.api.model.UpdateService; +import tc.oc.api.util.Permissions; +import tc.oc.commons.bukkit.chat.Audiences; +import tc.oc.commons.bukkit.chat.BukkitSound; +import tc.oc.commons.bukkit.nick.IdentityProvider; +import tc.oc.commons.bukkit.settings.SettingManagerProvider; +import tc.oc.commons.bukkit.teleport.PlayerServerChanger; +import tc.oc.commons.core.chat.Audience; +import tc.oc.commons.core.concurrent.Flexecutor; +import tc.oc.minecraft.api.event.Enableable; +import tc.oc.minecraft.scheduler.Sync; + +import static tc.oc.commons.bukkit.punishment.PunishmentMessageSetting.Options; +import static tc.oc.commons.bukkit.punishment.PunishmentPermissions.LOOK_UP; +import static tc.oc.commons.bukkit.punishment.PunishmentPermissions.LOOK_UP_STALE; + +@Singleton +public class PunishmentEnforcer implements Enableable, MessageListener { + + private final PunishmentFormatter punishmentFormatter; + private final UpdateService punishmentService; + private final MessageQueue queue; + private final Flexecutor executor; + private final Server localServer; + private final OnlinePlayers players; + private final IdentityProvider identities; + private final Audiences audiences; + private final PlayerServerChanger playerServerChanger; + private final SettingManagerProvider settings; + + @Inject PunishmentEnforcer(PunishmentFormatter punishmentFormatter, + UpdateService punishmentService, + MessageQueue queue, + @Sync Flexecutor executor, + Server localServer, + OnlinePlayers players, + IdentityProvider identities, + Audiences audiences, + PlayerServerChanger playerServerChanger, + SettingManagerProvider settings) { + + this.punishmentFormatter = punishmentFormatter; + this.punishmentService = punishmentService; + this.queue = queue; + this.executor = executor; + this.localServer = localServer; + this.players = players; + this.identities = identities; + this.audiences = audiences; + this.playerServerChanger = playerServerChanger; + this.settings = settings; + } + + @Override + public void enable() { + queue.bind(ModelUpdate.class); + queue.subscribe(this, executor); + } + + @Override + public void disable() { + queue.unsubscribe(this); + } + + @HandleMessage + private void onUpdate(ModelUpdate message) { + final Punishment punishment = message.document(); + if(!punishment.enforced()) { + announce(punishment); + enforce(punishment); + } + } + + private void enforce(Punishment punishment) { + players.byUserId(punishment.punished()).ifPresent(punished -> enforce(punishment, punished)); + } + + private void enforce(Punishment punishment, Player punished) { + final Audience audience = audiences.get(punished); + switch(punishment.type()) { + case WARN: + audience.playSound(new BukkitSound(Sound.ENTITY_ENDERDRAGON_GROWL, 1f, 1f)); + punishmentFormatter.warning(punishment) + .feed((title, subtitle) -> audience.showTitle(title, subtitle, 5, 200, 10)); + break; + case KICK: + case BAN: + playerServerChanger.kickPlayer(punished, punishmentFormatter.screen(punishment, punished)); + break; + } + + punishmentService.update(punishment._id(), (PunishmentDoc.Enforce) () -> true); + } + + private void announce(Punishment punishment) { + players.all() + .stream() + .filter(player -> viewable(player, punishment, true)) + .forEach(player -> audiences.get(player).sendMessages( + punishmentFormatter.format(punishment, true, + !punishment.server_id().equals(localServer._id())) + )); + } + + public boolean viewable(CommandSender sender, Punishment punishment, boolean announced) { + if(viewByIdentity(sender, punishment)) { + if(announced) { + return viewByType(sender, punishment) && viewBySetting(sender, punishment) && viewByIdentity(sender, punishment); + } else { + return viewByLookup(sender, punishment); + } + } + return false; + } + + private boolean viewByIdentity(CommandSender sender, Punishment punishment) { + return identities.currentOrConsoleIdentity(punishment.punisher()).isRevealed(sender); + } + + private boolean viewBySetting(CommandSender sender, Punishment punishment) { + switch(settings.tryManager(sender).map(m -> m.getValue(PunishmentMessageSetting.get(), Options.class, Options.SERVER)).orElse(Options.NONE)) { + case GLOBAL: + return true; + case SERVER: + return localServer._id().equals(punishment.server_id()); + case NONE: + default: + return false; + } + } + + private boolean viewByType(CommandSender sender, Punishment punishment) { + switch(punishment.type()) { + case WARN: + case FORUM_BAN: + case FORUM_WARN: + return sender.hasPermission(Permissions.STAFF); + case TOURNEY_BAN: + return localServer.network().equals(ServerDoc.Network.TOURNAMENT); + default: + return true; + } + } + + private boolean viewByLookup(CommandSender sender, Punishment punishment) { + return sender.hasPermission(punishment.stale() ? LOOK_UP_STALE : LOOK_UP); + } + +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/punishment/PunishmentFormatter.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/punishment/PunishmentFormatter.java new file mode 100644 index 0000000..f298fcd --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/punishment/PunishmentFormatter.java @@ -0,0 +1,112 @@ +package tc.oc.commons.bukkit.punishment; + +import java.util.ArrayList; +import java.util.List; +import javax.inject.Inject; + +import com.google.common.collect.ImmutableList; +import net.md_5.bungee.api.ChatColor; +import net.md_5.bungee.api.chat.BaseComponent; +import net.md_5.bungee.api.chat.TranslatableComponent; +import org.bukkit.command.CommandSender; +import tc.oc.api.docs.Punishment; +import tc.oc.api.servers.ServerStore; +import tc.oc.commons.bukkit.chat.ComponentRenderContext; +import tc.oc.commons.bukkit.chat.Links; +import tc.oc.commons.bukkit.chat.PlayerComponent; +import tc.oc.commons.bukkit.format.ServerFormatter; +import tc.oc.commons.bukkit.nick.IdentityProvider; +import tc.oc.commons.core.chat.Component; +import tc.oc.commons.core.chat.Components; +import tc.oc.commons.core.formatting.PeriodFormats; +import tc.oc.commons.core.util.Pair; + +public class PunishmentFormatter { + + private final static Component ARROW = new Component(" \u00BB ", ChatColor.GOLD); + private final static Component MAGIC = new Component(" \u26a0 ", ChatColor.YELLOW); + + private final IdentityProvider identityProvider; + private final ComponentRenderContext renderContext; + private final ServerStore servers; + + @Inject PunishmentFormatter(IdentityProvider identityProvider, ComponentRenderContext renderContext, ServerStore servers) { + this.identityProvider = identityProvider; + this.renderContext = renderContext; + this.servers = servers; + } + + public List format(Punishment punishment, boolean alert, boolean server) { + + List parts = new ArrayList<>(); + + if(alert) { + parts.add(new Component("[").extra(new TranslatableComponent("punishment.prefix"), ChatColor.GOLD).extra("]")); + if(server) { + servers.tryId(punishment.server_id()).ifPresent( + s -> parts.add(ServerFormatter.light.nameWithDatacenter(s)) + ); + } + } else { + parts.add(new Component(PeriodFormats.relativePastApproximate(punishment.date())).extra(":")); + } + + parts.add(new PlayerComponent(identityProvider.currentOrConsoleIdentity(punishment.punisher()))); + + parts.add(new Component(new TranslatableComponent("punishment.action." + punishment.type()))); + + parts.add(new Component(new PlayerComponent(identityProvider.currentOrConsoleIdentity(punishment.punished())))); + + if(punishment.expire() != null) { + parts.add(PeriodFormats.relativeFutureApproximate(punishment.date(), punishment.expire())); + } + + parts.get(parts.size() - 1).addExtra(":"); + + return ImmutableList.of( + Components.join(Components.space(), parts), + new Component(" > ").extra(new Component(punishment.reason(), ChatColor.YELLOW)) + ); + + } + + public Pair warning(Punishment punishment) { + return Pair.create( + new Component(MAGIC, new Component(new TranslatableComponent("punishment.warning"), ChatColor.RED), MAGIC), + new Component(punishment.reason(), ChatColor.AQUA) + ); + } + + public String screen(Punishment punishment, CommandSender sender) { + return renderContext.renderLegacy(screen(punishment), sender); + } + + public BaseComponent screen(Punishment punishment) { + + List parts = new ArrayList<>(); + + parts.add( + new Component( + new TranslatableComponent( + "punishment.screen." + punishment.type(), + punishment.expire() != null ? PeriodFormats.relativeFutureApproximate(punishment.date(), punishment.expire()) + : Components.blank() + ) + ) + ); + + parts.add(Components.blank()); + + parts.add(new Component(punishment.reason(), ChatColor.YELLOW)); + + parts.addAll(Components.repeat(Components::blank, 3)); + + parts.add(new Component(new TranslatableComponent("punishment.screen.rules", Links.rulesLink()))); + + parts.add(new Component(new TranslatableComponent("punishment.screen.appeal", Links.appealLink()))); + + return Components.join(Components.newline(), parts); + + } + +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/punishment/PunishmentManifest.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/punishment/PunishmentManifest.java new file mode 100644 index 0000000..740fc1b --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/punishment/PunishmentManifest.java @@ -0,0 +1,20 @@ +package tc.oc.commons.bukkit.punishment; + +import tc.oc.commons.bukkit.settings.SettingBinder; +import tc.oc.commons.core.commands.CommandBinder; +import tc.oc.commons.core.inject.HybridManifest; +import tc.oc.minecraft.api.event.ListenerBinder; + +public class PunishmentManifest extends HybridManifest { + @Override + protected void configure() { + new CommandBinder(binder()) + .register(PunishmentCommands.class); + + new ListenerBinder(binder()) + .bindListener().to(PunishmentEnforcer.class); + + new SettingBinder(publicBinder()) + .addBinding().toInstance(PunishmentMessageSetting.get()); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/punishment/PunishmentMessageSetting.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/punishment/PunishmentMessageSetting.java new file mode 100644 index 0000000..578c09b --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/punishment/PunishmentMessageSetting.java @@ -0,0 +1,31 @@ +package tc.oc.commons.bukkit.punishment; + +import me.anxuiz.settings.Setting; +import me.anxuiz.settings.SettingBuilder; +import me.anxuiz.settings.types.EnumType; +import me.anxuiz.settings.types.Name; + +public class PunishmentMessageSetting { + + private static final Setting setting = new SettingBuilder() + .name("PunishmentMessages").alias("punishments").alias("pmessages").alias("pmsgs") + .summary("Punishment messages shown to you") + .description("Options:\n" + + "GLOBAL: punishments from all servers\n" + + "SERVER: punishments from the current server\n" + + "NONE: no messages\n") + .type(new EnumType<>("Punishment Message Options", Options.class)) + .defaultValue(Options.SERVER) + .get(); + + public static Setting get() { + return setting; + } + + public enum Options { + @Name("global") GLOBAL, + @Name("server") SERVER, + @Name("none") NONE + } + +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/punishment/PunishmentPermissions.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/punishment/PunishmentPermissions.java new file mode 100644 index 0000000..54e84e8 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/punishment/PunishmentPermissions.java @@ -0,0 +1,32 @@ +package tc.oc.commons.bukkit.punishment; + +import tc.oc.api.docs.virtual.PunishmentDoc; + +import javax.annotation.Nullable; + +public interface PunishmentPermissions { + + String BASE = "ocn.punishments"; + String LOOK_UP = BASE + ".lookup"; + String LOOK_UP_STALE = LOOK_UP + ".stale"; + String PUNISH = BASE + ".punish"; + String PUNISH_AUTO = PUNISH + ".auto"; + String PUNISH_SILENT = PUNISH + ".silent"; + String PUNISH_OFF_RECORD = PUNISH + ".off_record"; + String PUNISH_TIME = PUNISH + ".time"; + + static String fromFlag(char flag) { + switch(flag) { + case 'p': return PUNISH_AUTO; + case 's': return PUNISH_SILENT; + case 'o': return PUNISH_OFF_RECORD; + case 't': return PUNISH_TIME; + default: return "null"; + } + } + + static String fromType(@Nullable PunishmentDoc.Type type) { + return type == null ? PUNISH : BASE + "." + type.permission(); + } + +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/raindrops/PlayerRecieveRaindropsEvent.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/raindrops/PlayerRecieveRaindropsEvent.java new file mode 100644 index 0000000..f7e36e7 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/raindrops/PlayerRecieveRaindropsEvent.java @@ -0,0 +1,42 @@ +package tc.oc.commons.bukkit.raindrops; + +import net.md_5.bungee.api.chat.BaseComponent; +import org.bukkit.entity.Player; +import org.bukkit.event.HandlerList; +import org.bukkit.event.player.PlayerEvent; + +public class PlayerRecieveRaindropsEvent extends PlayerEvent { + private static final HandlerList handlers = new HandlerList(); + + protected final int raindrops; + protected final int multiplier; + protected final BaseComponent reason; + + public PlayerRecieveRaindropsEvent(Player who, int raindrops, int multiplier, BaseComponent reason) { + super(who); + this.raindrops = raindrops; + this.multiplier = multiplier; + this.reason = reason; + } + + public int getRaindrops() { + return this.raindrops; + } + + public int getMultiplier() { + return this.multiplier; + } + + public BaseComponent getReason() { + return this.reason; + } + + @Override + public HandlerList getHandlers() { + return handlers; + } + + public static HandlerList getHandlerList() { + return handlers; + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/raindrops/RaindropCommands.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/raindrops/RaindropCommands.java new file mode 100644 index 0000000..7c0452b --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/raindrops/RaindropCommands.java @@ -0,0 +1,67 @@ +package tc.oc.commons.bukkit.raindrops; + +import javax.inject.Inject; + +import com.sk89q.minecraft.util.commands.Command; +import com.sk89q.minecraft.util.commands.CommandContext; +import com.sk89q.minecraft.util.commands.CommandException; +import net.md_5.bungee.api.ChatColor; +import net.md_5.bungee.api.chat.TranslatableComponent; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import tc.oc.minecraft.scheduler.SyncExecutor; +import tc.oc.commons.bukkit.chat.Audiences; +import tc.oc.commons.bukkit.chat.PlayerComponent; +import tc.oc.commons.bukkit.commands.UserFinder; +import tc.oc.commons.bukkit.nick.IdentityProvider; +import tc.oc.commons.core.chat.Component; +import tc.oc.commons.core.commands.CommandFutureCallback; +import tc.oc.commons.core.commands.Commands; + +/** + * General raindrop related commands + */ +public class RaindropCommands implements Commands { + + private final UserFinder userFinder; + private final SyncExecutor executor; + private final Audiences audiences; + private final IdentityProvider identityProvider; + + @Inject RaindropCommands(UserFinder userFinder, SyncExecutor executor, Audiences audiences, IdentityProvider identityProvider) { + this.userFinder = userFinder; + this.executor = executor; + this.audiences = audiences; + this.identityProvider = identityProvider; + } + + @Command( + aliases = {"raindrops", "rds"}, + usage = "[player]", + desc = "Shows the amount of raindrops that you have", + min = 0, + max = 1 + ) + public void raindrops(final CommandContext args, final CommandSender sender) throws CommandException { + executor.callback( + userFinder.findUser(sender, args, 0, UserFinder.Default.SENDER), + CommandFutureCallback.onSuccess(sender, args, result -> { + final boolean self = sender instanceof Player && ((Player) sender).getUniqueId().equals(result.user.uuid()); + final int raindrops; + if(result.disguised && result.last_session != null) { + // Generate a pseudo-random amount of raindrops between ~1000 and 100,000 + raindrops = 10000000 / (100 + Math.abs(result.last_session.nickname().hashCode() % 9900)); + } else { + raindrops = result.user.raindrops(); + } + audiences.get(sender).sendMessage( + new Component(ChatColor.WHITE) + .translate(self ? "raindrops.balance.self" : "raindrops.balance.other", + new PlayerComponent(identityProvider.createIdentity(result)), + new Component(String.format("%,d", raindrops), ChatColor.AQUA), + new TranslatableComponent(Math.abs(raindrops) == 1 ? "raindrops.singular" : "raindrops.plural")) + ); + }) + ); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/raindrops/RaindropConstants.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/raindrops/RaindropConstants.java new file mode 100644 index 0000000..377ad53 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/raindrops/RaindropConstants.java @@ -0,0 +1,23 @@ +package tc.oc.commons.bukkit.raindrops; + +import java.time.Duration; + +public class RaindropConstants { + public static final int TEAM_REWARD = 5; + public static final double LOSING_TEAM_REWARD_PERCENT = 0.3; + public static final Duration TEAM_REWARD_CUTOFF = Duration.ofMinutes(2); + + public static final int TOUCH_GOAL_REWARD = 5; + public static final int WOOL_PLACE_REWARD = 10; + public static final int WOOL_DESTROY_REWARD = 5; + public static final int DESTROYABLE_DESTROY_PERCENT_REWARD = 10; + public static final int KILL_REWARD = 1; + + public static final double PLAY_TIME_BONUS = 3; + public static final int PLAY_TIME_BONUS_CUTOFF = 10; + public static final int MATCH_FULLNESS_BONUS = 10; + + public static final int MULTIPLIER_BASE = 100; + public static final int MULTIPLIER_MAX = 500; + public static final int MULTIPLIER_INCREMENT = 25; +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/raindrops/RaindropManifest.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/raindrops/RaindropManifest.java new file mode 100644 index 0000000..3ca95fe --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/raindrops/RaindropManifest.java @@ -0,0 +1,22 @@ +package tc.oc.commons.bukkit.raindrops; + +import org.bukkit.permissions.Permission; +import org.bukkit.permissions.PermissionDefault; +import org.bukkit.permissions.PermissionBinder; +import tc.oc.commons.core.inject.HybridManifest; +import tc.oc.commons.core.plugin.PluginFacetBinder; + +public class RaindropManifest extends HybridManifest { + @Override + protected void configure() { + requestStaticInjection(RaindropUtil.class); + + new PluginFacetBinder(binder()) + .register(RaindropCommands.class); + + final PermissionBinder permissions = new PermissionBinder(binder()); + for(int i = RaindropConstants.MULTIPLIER_MAX; i > 0; i = i - RaindropConstants.MULTIPLIER_INCREMENT) { + permissions.bindPermission().toInstance(new Permission("raindrops.multiplier." + i, PermissionDefault.FALSE)); + } + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/raindrops/RaindropResult.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/raindrops/RaindropResult.java new file mode 100644 index 0000000..151b01a --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/raindrops/RaindropResult.java @@ -0,0 +1,13 @@ +package tc.oc.commons.bukkit.raindrops; + +public abstract class RaindropResult implements Runnable { + protected boolean success = false; + + public void setSuccess(boolean newValue) { + this.success = newValue; + run(); + } + + @Override + public abstract void run(); +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/raindrops/RaindropUtil.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/raindrops/RaindropUtil.java new file mode 100644 index 0000000..ef4ab96 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/raindrops/RaindropUtil.java @@ -0,0 +1,170 @@ +package tc.oc.commons.bukkit.raindrops; + +import javax.annotation.Nullable; +import javax.inject.Inject; + +import net.md_5.bungee.api.ChatColor; +import net.md_5.bungee.api.chat.BaseComponent; +import org.bukkit.Material; +import org.bukkit.Sound; +import org.bukkit.entity.Player; +import org.bukkit.event.EventBus; +import org.bukkit.inventory.ItemStack; +import org.bukkit.plugin.Plugin; +import java.time.Duration; +import tc.oc.api.bukkit.users.BukkitUserStore; +import tc.oc.api.docs.PlayerId; +import tc.oc.api.users.UserService; +import tc.oc.commons.bukkit.chat.Audiences; +import tc.oc.commons.bukkit.util.NMSHacks; +import tc.oc.commons.bukkit.util.SyncPlayerExecutorFactory; +import tc.oc.commons.core.chat.Audience; +import tc.oc.commons.core.chat.Component; + +public class RaindropUtil { + + @Inject private static Plugin plugin; + @Inject private static BukkitUserStore userStore; + @Inject private static UserService userService; + @Inject private static SyncPlayerExecutorFactory playerExecutorFactory; + @Inject private static EventBus eventBus; + @Inject private static Audiences audiences; + + public static int useMultiplier(int count, int multiplier) { + return (int) (count * multiplier / 100f); + } + + public static int calculateMultiplier(PlayerId playerId) { + final Player player = userStore.find(playerId); + int multiplier = RaindropConstants.MULTIPLIER_BASE; + if(player != null) { + for(int i = RaindropConstants.MULTIPLIER_MAX; i > 0; i = i - RaindropConstants.MULTIPLIER_INCREMENT) { + if(player.hasPermission("raindrops.multiplier." + i)) { + multiplier = i; + break; + } + } + } + return multiplier; + } + + public static int calculateRaindrops(PlayerId playerId, int delta, boolean useMultiplier) { + if(delta == 0) return 0; + if(useMultiplier) { + delta = useMultiplier(delta, calculateMultiplier(playerId)); + } + return delta; + } + + public static void giveRaindrops(PlayerId playerId, int count, @Nullable RaindropResult result) { + giveRaindrops(playerId, count, result, null); + } + + public static void giveRaindrops(PlayerId playerId, int count, @Nullable RaindropResult result, @Nullable BaseComponent reason) { + giveRaindrops(playerId, count, result, reason, true); + } + + public static void giveRaindrops(PlayerId playerId, int delta, @Nullable RaindropResult result, @Nullable BaseComponent reason, boolean useMultiplier) { + giveRaindrops(playerId, delta, result, reason, useMultiplier, true); + } + + public static void giveRaindrops(PlayerId playerId, int delta, @Nullable RaindropResult result, @Nullable BaseComponent reason, boolean useMultiplier, boolean save) { + giveRaindrops(playerId, delta, result, reason, useMultiplier, save, true); + } + + public static void giveRaindrops(PlayerId playerId, int delta, @Nullable RaindropResult result, @Nullable BaseComponent reason, boolean useMultiplier, boolean save, boolean show) { + if(delta == 0) return; + + final int multiplier; + if(useMultiplier) { + multiplier = calculateMultiplier(playerId); + delta = useMultiplier(delta, multiplier); + } else { + multiplier = RaindropConstants.MULTIPLIER_BASE; + } + + giveRaindrops(playerId, delta, multiplier, result, reason, save, show); + } + + public static void giveRaindrops(PlayerId playerId, int delta, int multiplier, @Nullable RaindropResult result, @Nullable BaseComponent reason, boolean save) { + giveRaindrops(playerId, delta, multiplier, result, reason, save, true); + } + + public static void giveRaindrops(PlayerId playerId, int delta, int multiplier, @Nullable RaindropResult result, @Nullable BaseComponent reason, boolean save, boolean show) { + final int countBefore = userStore.getUser(playerId).raindrops(); + + if(countBefore + delta < 0) { + if(result != null) { + result.setSuccess(false); + } + return; + } + + if(save) { + final int finalDelta = delta; + playerExecutorFactory.queued(playerId).callback( + userService.creditRaindrops(playerId, finalDelta), + (player, update) -> { + if(update.success()) { + showRaindrops(player, finalDelta, multiplier, reason, show); + } + if(result != null) { + result.setSuccess(update.success()); + } + } + ); + } else { + final Player player = userStore.find(playerId); + if(player != null) { + showRaindrops(player, delta, multiplier, reason, show); + } + if(result != null) { + result.setSuccess(true); + } + } + } + + public static void showRaindrops(Player player, int delta, int multiplier, @Nullable BaseComponent reason) { + showRaindrops(player, delta, multiplier, reason, true); + } + + public static void showRaindrops(Player player, int delta, int multiplier, @Nullable BaseComponent reason, boolean show) { + eventBus.callEvent(new PlayerRecieveRaindropsEvent(player, delta, multiplier, reason)); + if (show) { + final Audience audience = audiences.get(player); + audience.sendMessage(raindropsMessage(delta, multiplier, reason)); + player.playSound(player.getLocation(), Sound.ENTITY_PLAYER_LEVELUP, 1f, 1.5f); + raindropDisplay(player, delta); + } + } + + public static void raindropDisplay(final Player player, int count) { + if(count == 1) { + count = 2; + } else if(count > 12) { + count = 12; + } + + NMSHacks.showFakeItems(plugin, + player, + player.getLocation().add(0, 2, 0), + new ItemStack(Material.GHAST_TEAR), + count, + Duration.ofSeconds(3)); + } + + private static BaseComponent raindropsMessage(int count, int multiplier, @Nullable BaseComponent reason) { + Component message = new Component(ChatColor.GRAY); + message.extra(new Component((count > 0 ? "+" : "") + count, ChatColor.GREEN, ChatColor.BOLD), + new Component(" Raindrop" + (count == 1 || count == -1 ? "" : "s"), ChatColor.AQUA)); + if(multiplier != 100) { + message.extra(new Component(" | ", ChatColor.DARK_PURPLE), + new Component((multiplier / 100f) + "x", ChatColor.GOLD, ChatColor.ITALIC)); + } + if(reason != null) { + message.extra(new Component(" | ", ChatColor.DARK_PURPLE), + reason); + } + return message; + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/report/ReportAnnouncer.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/report/ReportAnnouncer.java new file mode 100644 index 0000000..33ea089 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/report/ReportAnnouncer.java @@ -0,0 +1,66 @@ +package tc.oc.commons.bukkit.report; + +import java.util.List; +import javax.inject.Inject; +import javax.inject.Singleton; + +import net.md_5.bungee.api.chat.BaseComponent; +import tc.oc.api.docs.Report; +import tc.oc.api.docs.Server; +import tc.oc.api.message.MessageListener; +import tc.oc.api.message.MessageQueue; +import tc.oc.api.message.types.ModelUpdate; +import tc.oc.commons.bukkit.channels.AdminChannel; +import tc.oc.commons.bukkit.chat.Audiences; +import tc.oc.commons.core.plugin.PluginFacet; +import tc.oc.minecraft.scheduler.MainThreadExecutor; + +@Singleton +public class ReportAnnouncer implements PluginFacet, MessageListener { + + private final ReportConfiguration config; + private final ReportFormatter reportFormatter; + private final MessageQueue primaryQueue; + private final MainThreadExecutor executor; + private final Server localServer; + private final AdminChannel adminChannel; + private final Audiences audiences; + + @Inject ReportAnnouncer(ReportConfiguration config, ReportFormatter reportFormatter, MessageQueue primaryQueue, MainThreadExecutor executor, Server localServer, AdminChannel adminChannel, Audiences audiences) { + this.config = config; + this.reportFormatter = reportFormatter; + this.primaryQueue = primaryQueue; + this.executor = executor; + this.localServer = localServer; + this.adminChannel = adminChannel; + this.audiences = audiences; + } + + @Override + public boolean isActive() { + return config.crossServer(); + } + + @Override + public void enable() { + primaryQueue.bind(ModelUpdate.class); + primaryQueue.subscribe(this, executor); + } + + @Override + public void disable() { + primaryQueue.unsubscribe(this); + } + + @HandleMessage + public void broadcast(ModelUpdate message) { + if(localServer._id().equals(message.document().server_id()) || + (config.crossServer() && config.families().contains(message.document().family()))) { + + final List formatted = reportFormatter.format(message.document(), true, false); + adminChannel.viewers() + .filter(viewer -> viewer.hasPermission(ReportPermissions.RECEIVE)) + .forEach(viewer -> audiences.get(viewer).sendMessages(formatted)); + } + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/report/ReportCommands.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/report/ReportCommands.java new file mode 100644 index 0000000..82896bc --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/report/ReportCommands.java @@ -0,0 +1,195 @@ +package tc.oc.commons.bukkit.report; + +import java.util.Map; +import java.util.WeakHashMap; +import javax.inject.Inject; +import javax.inject.Singleton; + +import com.sk89q.minecraft.util.commands.Command; +import com.sk89q.minecraft.util.commands.CommandContext; +import com.sk89q.minecraft.util.commands.CommandException; +import com.sk89q.minecraft.util.commands.CommandPermissions; +import net.md_5.bungee.api.ChatColor; +import net.md_5.bungee.api.chat.TranslatableComponent; +import org.bukkit.command.CommandSender; +import org.bukkit.command.ConsoleCommandSender; +import org.bukkit.entity.Player; +import java.time.Duration; +import java.time.Instant; + +import org.bukkit.event.EventHandler; +import org.bukkit.event.player.PlayerQuitEvent; +import tc.oc.api.bukkit.users.BukkitUserStore; +import tc.oc.api.docs.Report; +import tc.oc.api.docs.Server; +import tc.oc.minecraft.scheduler.SyncExecutor; +import tc.oc.api.model.QueryService; +import tc.oc.api.reports.ReportSearchRequest; +import tc.oc.commons.bukkit.chat.Audiences; +import tc.oc.commons.bukkit.chat.HeaderComponent; +import tc.oc.commons.bukkit.chat.NameStyle; +import tc.oc.commons.bukkit.chat.PlayerComponent; +import tc.oc.commons.bukkit.commands.UserFinder; +import tc.oc.commons.bukkit.nick.IdentityProvider; +import tc.oc.commons.core.chat.Audience; +import tc.oc.commons.core.chat.Component; +import tc.oc.commons.core.commands.CommandFutureCallback; +import tc.oc.commons.core.commands.Commands; +import tc.oc.commons.core.commands.TranslatableCommandException; +import tc.oc.commons.core.formatting.PeriodFormats; +import tc.oc.commons.core.util.Comparables; +import tc.oc.minecraft.api.event.Listener; + +@Singleton +public class ReportCommands implements Commands, Listener { + + private static final int PER_PAGE = 8; + + private final ReportFormatter reportFormatter; + private final QueryService reportService; + private final ReportCreator reportCreator; + private final ReportConfiguration reportConfiguration; + private final UserFinder userFinder; + private final SyncExecutor syncExecutor; + private final Server localServer; + private final BukkitUserStore userStore; + private final Audiences audiences; + private final IdentityProvider identities; + + private final Map senderLastReport = new WeakHashMap<>(); + + @Inject ReportCommands(ReportFormatter reportFormatter, + QueryService reportService, + ReportCreator reportCreator, + ReportConfiguration reportConfiguration, + UserFinder userFinder, + SyncExecutor syncExecutor, + Server localServer, + BukkitUserStore userStore, + Audiences audiences, + IdentityProvider identities) { + this.reportFormatter = reportFormatter; + this.reportService = reportService; + this.reportCreator = reportCreator; + this.reportConfiguration = reportConfiguration; + this.userFinder = userFinder; + this.syncExecutor = syncExecutor; + this.localServer = localServer; + this.userStore = userStore; + this.audiences = audiences; + this.identities = identities; + } + + @EventHandler + private void onQuit(PlayerQuitEvent event) { + senderLastReport.remove(event.getPlayer()); + } + + private void assertEnabled() throws CommandException { + if(!reportConfiguration.enabled()) { + throw new TranslatableCommandException("command.reports.notEnabled"); + } + } + + @Command( + aliases = { "report" }, + usage = " ", + desc = "Report a player who is breaking the rules", + min = 2, + max = -1 + ) + @CommandPermissions(ReportPermissions.CREATE) + public void report(final CommandContext args, final CommandSender sender) throws CommandException { + assertEnabled(); + + if(!sender.hasPermission(ReportPermissions.COOLDOWN_EXEMPT)) { + final Instant lastReportTime = senderLastReport.get(sender); + if(lastReportTime != null) { + final Duration timeLeft = reportConfiguration.cooldown().minus(Duration.between(lastReportTime, Instant.now())); + if(Comparables.greaterThan(timeLeft, Duration.ZERO)) { + throw new TranslatableCommandException("command.report.cooldown", PeriodFormats.briefNaturalApproximate(timeLeft)); + } + } + } + + syncExecutor.callback( + userFinder.findLocalPlayer(sender, args, 0), + CommandFutureCallback.onSuccess(sender, args, response -> { + if(response.player().hasPermission(ReportPermissions.EXEMPT) && !sender.hasPermission(ReportPermissions.OVERRIDE)) { + throw new TranslatableCommandException("command.report.exempt"); + } + + senderLastReport.put(sender, Instant.now()); + + syncExecutor.callback( + reportCreator.createReport( + response.user, + sender instanceof Player ? userStore.getUser((Player) sender) : null, + args.getJoinedStrings(1), + sender instanceof ConsoleCommandSender + ), + CommandFutureCallback.onSuccess(sender, args, report -> { + audiences.get(sender).sendMessage( + new Component( + new Component(new TranslatableComponent("misc.thankYou"), ChatColor.GREEN), + new Component(" "), + new Component(new TranslatableComponent("command.report.successful.dealtWithMessage"), ChatColor.GOLD) + ) + ); + }) + ); + }) + ); + } + + @Command( + aliases = { "reports", "reps" }, + usage = "[-a] [-p page] [player]", + flags = "ap:", + desc = "List recent reports on this server, or all servers, optionally filtering by player.", + min = 0, + max = 1 + ) + @CommandPermissions(ReportPermissions.VIEW) + public void reports(final CommandContext args, final CommandSender sender) throws CommandException { + assertEnabled(); + + syncExecutor.callback( + userFinder.findUser(sender, args.getString(0, null), UserFinder.Scope.ALL, UserFinder.Default.NULL), + CommandFutureCallback.onSuccess(sender, args, userResult -> { + final int page = args.getFlagInteger('p', 1); + final boolean crossServer = args.hasFlag('a'); + + ReportSearchRequest request = ReportSearchRequest.create(page, PER_PAGE); + request = crossServer ? request.forFamilies(reportConfiguration.families()) + : request.forServer(localServer); + if(userResult != null) { + request = request.forPlayer(userResult.user); + } + + syncExecutor.callback( + reportService.find(request), + CommandFutureCallback.onSuccess(sender, args, reportResult -> { + final Component title = new Component(new TranslatableComponent(crossServer ? "command.reports.networkTitle" : "command.reports.serverTitle"), + ChatColor.YELLOW); + if(userResult != null) { + title.extra(" (") + .extra(new PlayerComponent(identities.createIdentity(userResult), NameStyle.VERBOSE)) + .extra(")"); + } + title.extra(" ") + .extra(new TranslatableComponent("currentPage", String.valueOf(page)), ChatColor.DARK_AQUA); + + final Audience audience = audiences.get(sender); + audience.sendMessage(new HeaderComponent(title)); + for(Report report : reportResult.documents()) { + if(report.reported() != null) { + audience.sendMessages(reportFormatter.format(report, crossServer, true)); + } + } + }) + ); + }) + ); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/report/ReportConfiguration.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/report/ReportConfiguration.java new file mode 100644 index 0000000..2780132 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/report/ReportConfiguration.java @@ -0,0 +1,33 @@ +package tc.oc.commons.bukkit.report; + +import java.util.List; +import javax.inject.Inject; + +import org.bukkit.configuration.Configuration; +import java.time.Duration; +import tc.oc.commons.bukkit.configuration.ConfigUtils; + +public class ReportConfiguration { + + private final Configuration config; + + @Inject ReportConfiguration(Configuration config) { + this.config = config; + } + + public boolean enabled() { + return config.getBoolean("reports.enabled", false); + } + + public Duration cooldown() { + return ConfigUtils.getDuration(config, "reports.cooldown", Duration.ZERO); + } + + public List families() { + return config.getStringList("reports.families"); + } + + public boolean crossServer() { + return config.getBoolean("reports.cross-server", false); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/report/ReportCreator.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/report/ReportCreator.java new file mode 100644 index 0000000..8047b43 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/report/ReportCreator.java @@ -0,0 +1,103 @@ +package tc.oc.commons.bukkit.report; + +import java.util.EnumSet; +import java.util.List; +import java.util.stream.Collectors; +import javax.annotation.Nullable; +import javax.inject.Inject; +import javax.inject.Singleton; + +import com.google.common.util.concurrent.ListenableFuture; +import org.bukkit.GameMode; +import org.bukkit.entity.Player; +import tc.oc.api.bukkit.users.BukkitUserStore; +import tc.oc.api.bukkit.users.OnlinePlayers; +import tc.oc.api.docs.PlayerId; +import tc.oc.api.docs.Report; +import tc.oc.api.docs.Server; +import tc.oc.api.docs.virtual.MatchDoc; +import tc.oc.api.docs.virtual.ReportDoc; +import tc.oc.api.docs.virtual.ServerDoc; +import tc.oc.api.model.IdFactory; +import tc.oc.api.model.ModelService; +import tc.oc.api.util.Permissions; + +@Singleton +public class ReportCreator { + + private static final EnumSet MODERATING_GAMEMODES = EnumSet.of(GameMode.CREATIVE, GameMode.SPECTATOR); + + private final ModelService reportService; + private final OnlinePlayers onlinePlayers; + private final BukkitUserStore userStore; + private final IdFactory idFactory; + private final Server localServer; + + @Inject ReportCreator(ModelService reportService, OnlinePlayers onlinePlayers, BukkitUserStore userStore, IdFactory idFactory, Server localServer) { + this.reportService = reportService; + this.onlinePlayers = onlinePlayers; + this.userStore = userStore; + this.idFactory = idFactory; + this.localServer = localServer; + } + + private boolean isModerating(Player player) { + // HACK - we should have a better way to detect this + return player.hasPermission(Permissions.STAFF) && + (MODERATING_GAMEMODES.contains(player.getGameMode()) || + localServer.role() != ServerDoc.Role.PGM); + } + + public ListenableFuture createReport(PlayerId reportedId, @Nullable PlayerId reporterId, String reason, boolean automatic) { + final String _id = idFactory.newId(); + + return reportService.update( + new ReportDoc.Creation() { + @Override public String _id() { + return _id; + } + + @Override public String scope() { + return "game"; + } + + @Override public boolean automatic() { + return automatic; + } + + @Override public String family() { + return localServer.family(); + } + + @Override public String server_id() { + return localServer._id(); + } + + @Override public String match_id() { + final MatchDoc match = localServer.current_match(); + return match == null ? null : match._id(); + } + + @Override public String reporter_id() { + return reporterId == null ? null : reporterId._id(); + } + + @Override public String reported_id() { + return reportedId._id(); + } + + @Override public String reason() { + return reason; + } + + @Override public List staff_online() { + return onlinePlayers.all() + .stream() + .filter(ReportCreator.this::isModerating) + .map(player -> userStore.getUser(player).player_id()) + .collect(Collectors.toList()); + } + } + ); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/report/ReportFormatter.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/report/ReportFormatter.java new file mode 100644 index 0000000..eef3993 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/report/ReportFormatter.java @@ -0,0 +1,76 @@ +package tc.oc.commons.bukkit.report; + +import java.util.ArrayList; +import java.util.List; +import javax.inject.Inject; + +import com.google.common.collect.ImmutableList; +import net.md_5.bungee.api.ChatColor; +import net.md_5.bungee.api.chat.BaseComponent; +import tc.oc.api.docs.Report; +import tc.oc.api.servers.ServerStore; +import tc.oc.commons.bukkit.chat.NameStyle; +import tc.oc.commons.bukkit.chat.PlayerComponent; +import tc.oc.commons.bukkit.commands.CommandUtils; +import tc.oc.commons.bukkit.format.ServerFormatter; +import tc.oc.commons.bukkit.nick.IdentityProvider; +import tc.oc.commons.core.chat.Component; +import tc.oc.commons.core.chat.Components; +import tc.oc.commons.core.formatting.PeriodFormats; + +public class ReportFormatter { + + private final IdentityProvider identityProvider; + private final ServerStore servers; + + @Inject ReportFormatter(IdentityProvider identityProvider, ServerStore servers) { + this.identityProvider = identityProvider; + this.servers = servers; + } + + public List format(Report report, boolean showServer, boolean showTime) { + final List parts = new ArrayList<>(); + + parts.add(new Component( + new Component("["), + new Component("Rep", ChatColor.GOLD), + new Component("]") + )); + + if(showServer) { + // Server may be soft-deleted, so we can't assume it's synced + servers.tryId(report.server_id()).ifPresent( + server -> parts.add(ServerFormatter.light.nameWithDatacenter(server)) + ); + } + + if(report.staff_online() != null) { + final int modCount = report.staff_online().size(); + parts.add(new Component( + new Component("("), + new Component(modCount, modCount > 0 ? ChatColor.GREEN : ChatColor.RED), + new Component(")") + )); + } + + if(showTime) { + parts.add(new Component(PeriodFormats.relativePastApproximate(report.created_at()), ChatColor.GREEN)); + } + + if(report.reporter() != null) { + parts.add(new PlayerComponent(identityProvider.currentIdentity(report.reporter()), NameStyle.FANCY)); + } else { + parts.add(CommandUtils.CONSOLE_COMPONENT_NAME); + } + + parts.add(new Component("\u2794")); + + parts.add(new Component( + new PlayerComponent(identityProvider.currentIdentity(report.reported()), NameStyle.FANCY), + new Component(": ") + )); + + return ImmutableList.of(Components.join(Components.space(), parts), + new Component(" " + report.reason(), ChatColor.LIGHT_PURPLE)); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/report/ReportPermissions.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/report/ReportPermissions.java new file mode 100644 index 0000000..a8e9972 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/report/ReportPermissions.java @@ -0,0 +1,10 @@ +package tc.oc.commons.bukkit.report; + +public interface ReportPermissions { + String RECEIVE = "projectares.reports.receive"; + String CREATE = "projectares.report"; + String VIEW = "projectares.reports.view"; + String EXEMPT = "projectares.report.exempt"; + String OVERRIDE = "projectares.report.override"; + String COOLDOWN_EXEMPT = "projectares.report.cooldown.exempt"; +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/respack/ResourcePackCommands.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/respack/ResourcePackCommands.java new file mode 100644 index 0000000..65b835f --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/respack/ResourcePackCommands.java @@ -0,0 +1,81 @@ +package tc.oc.commons.bukkit.respack; + +import javax.inject.Inject; + +import com.sk89q.minecraft.util.commands.Command; +import com.sk89q.minecraft.util.commands.CommandContext; +import com.sk89q.minecraft.util.commands.CommandException; +import com.sk89q.minecraft.util.commands.CommandPermissions; +import com.sk89q.minecraft.util.commands.NestedCommand; +import org.bukkit.command.CommandSender; +import tc.oc.commons.core.commands.Commands; +import tc.oc.commons.core.commands.NestedCommands; + +public class ResourcePackCommands implements NestedCommands { + private static final String PERMISSION = "ocn.command.respack"; + + public static class Parent implements Commands { + @Command( + aliases = {"respack"}, + desc = "Commands to manage custom resource packs", + min = 1, + max = -1 + ) + @NestedCommand({ResourcePackCommands.class}) + @CommandPermissions(PERMISSION) + public void respack() {} + } + + private final ResourcePackManager manager; + + @Inject ResourcePackCommands(ResourcePackManager manager) { + this.manager = manager; + } + + @Command( + aliases = {"status"}, + desc = "Show info about the custom resource pack", + min = 0, + max = 0 + ) + public void status(CommandContext args, CommandSender sender) throws CommandException { + sender.sendMessage("Custom resource packs are locally " + (manager.isEnabled() ? "ENABLED" : "DISABLED")); + sender.sendMessage("Fast updating is " + (manager.isFastUpdate() ? "ENABLED" : "DISABLED")); + if(manager.getUrl() == null) { + sender.sendMessage("No resource pack is configured for this server"); + } else { + sender.sendMessage("URL: " + manager.getUrl()); + sender.sendMessage("SHA1: " + manager.getSha1()); + } + } + + @Command( + aliases = {"enable"}, + desc = "Enable the custom resource pack", + min = 0, + max = 0 + ) + public void enable(CommandContext args, CommandSender sender) throws CommandException { + if(manager.isEnabled()) { + sender.sendMessage("Custom resource pack already enabled"); + } else { + sender.sendMessage("Enabling custom resource pack"); + manager.setEnabled(true); + } + } + + @Command( + aliases = {"disable"}, + desc = "Disable the custom resource pack", + min = 0, + max = 0 + ) + public void disable(CommandContext args, CommandSender sender) throws CommandException { + if(manager.isEnabled()) { + sender.sendMessage("Disabling custom resource pack"); + manager.setEnabled(false); + } else { + sender.sendMessage("Custom resource pack already disabled"); + } + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/respack/ResourcePackListener.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/respack/ResourcePackListener.java new file mode 100644 index 0000000..934ff38 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/respack/ResourcePackListener.java @@ -0,0 +1,161 @@ +package tc.oc.commons.bukkit.respack; + +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; +import java.util.logging.Logger; +import javax.annotation.Nullable; +import javax.inject.Inject; +import javax.inject.Singleton; + +import com.google.common.eventbus.Subscribe; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerResourcePackStatusEvent; +import org.bukkit.plugin.Plugin; +import tc.oc.api.bukkit.users.Users; +import tc.oc.api.docs.virtual.UserDoc; +import tc.oc.api.minecraft.MinecraftService; +import tc.oc.api.minecraft.servers.LocalServerReconfigureEvent; +import tc.oc.api.users.UserService; +import tc.oc.commons.bukkit.util.OnlinePlayerMapAdapter; +import tc.oc.commons.core.logging.Loggers; +import tc.oc.commons.core.plugin.PluginFacet; + +@Singleton +public class ResourcePackListener implements ResourcePackManager, Listener, PluginFacet { + + // Minimum time a player must be connected before sending them a res pack. + // If we send it too soon, the client behaves badly. + private static final Duration JOIN_DELAY = Duration.ofSeconds(1); + + private final Logger logger; + private final Plugin plugin; + private final MinecraftService minecraftService; + private final UserService userService; + + private boolean enabled = true; + private final OnlinePlayerMapAdapter lastSentSha1; + private final OnlinePlayerMapAdapter joinTime; + + @Inject ResourcePackListener(Loggers loggers, Plugin plugin, MinecraftService minecraftService, UserService userService, OnlinePlayerMapAdapter lastSentSha1, OnlinePlayerMapAdapter joinTime) { + this.logger = loggers.get(getClass()); + this.userService = userService; + this.lastSentSha1 = lastSentSha1; + this.joinTime = joinTime; + this.minecraftService = minecraftService; + this.plugin = plugin; + } + + @Override + public void enable() { + lastSentSha1.enable(); + joinTime.enable(); + } + + @Override + public void disable() { + joinTime.disable(); + lastSentSha1.disable(); + } + + @Override + public boolean isEnabled() { + return enabled; + } + + @Override + public void setEnabled(boolean enabled) { + if(this.enabled != enabled) { + this.enabled = enabled; + if(enabled && isFastUpdate()) { + refreshAll(); + } + } + } + + @Override + public boolean isFastUpdate() { + return minecraftService.getLocalServer().resource_pack_fast_update(); + } + + @Override + public @Nullable String getUrl() { + return minecraftService.getLocalServer().resource_pack_url(); + } + + @Override + public @Nullable String getSha1() { + return minecraftService.getLocalServer().resource_pack_sha1(); + } + + @Override + public void refreshPlayer(final Player player) { + if(!enabled) return; + if(!player.isOnline()) return; + + String url = getUrl(); + String sha1 = getSha1(); + if(url == null || sha1 == null) return; + + if(!Objects.equals(lastSentSha1.get(player), sha1)) { + Instant joined = joinTime.get(player); + if(joined == null) return; + long delayMillis = Duration.between(Instant.now(), joined.plus(JOIN_DELAY)).toMillis(); + + if(delayMillis <= 0) { + logger.fine("Sending resource pack " + url + " with SHA1 " + sha1 + " to player " + player.getName()); + lastSentSha1.put(player, sha1); + player.setResourcePack(url, sha1); + } else { + plugin.getServer().getScheduler().runTaskLater(plugin, new Runnable() { + @Override + public void run() { + refreshPlayer(player); + } + }, delayMillis / 50 + 1); + } + } + } + + @Override + public void refreshAll() { + for(Player player : plugin.getServer().getOnlinePlayers()) { + refreshPlayer(player); + } + } + + @EventHandler + public void join(PlayerJoinEvent event) { + this.joinTime.put(event.getPlayer(), Instant.now()); + refreshPlayer(event.getPlayer()); + } + + @Subscribe + public void reconfigure(LocalServerReconfigureEvent event) { + if(event.getNewConfig().resource_pack_fast_update()) { + String oldSha1 = event.getOldConfig() == null ? null : event.getOldConfig().resource_pack_sha1(); + String newSha1 = event.getNewConfig().resource_pack_sha1(); + if(!Objects.equals(oldSha1, newSha1)) refreshAll(); + } + } + + @EventHandler + public void confirm(final PlayerResourcePackStatusEvent event) { + logger.fine("Player " + event.getPlayer().getName() + " sent res pack status " + event.getStatus()); + final UserDoc.ResourcePackStatus status; + switch(event.getStatus()) { + case ACCEPTED: status = UserDoc.ResourcePackStatus.ACCEPTED; break; + case DECLINED: status = UserDoc.ResourcePackStatus.DECLINED; break; + case SUCCESSFULLY_LOADED: status = UserDoc.ResourcePackStatus.LOADED; break; + case FAILED_DOWNLOAD: status = UserDoc.ResourcePackStatus.FAILED; break; + default: throw new IllegalStateException("Unknown status " + event.getStatus()); + } + userService.update( + Users.playerId(event.getPlayer()), + (UserDoc.ResourcePackResponse) () -> status + ); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/respack/ResourcePackManager.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/respack/ResourcePackManager.java new file mode 100644 index 0000000..784e2c0 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/respack/ResourcePackManager.java @@ -0,0 +1,28 @@ +package tc.oc.commons.bukkit.respack; + +import javax.annotation.Nullable; + +import org.bukkit.entity.Player; + +public interface ResourcePackManager { + + boolean isEnabled(); + + void setEnabled(boolean enabled); + + boolean isFastUpdate(); + + @Nullable String getUrl(); + + @Nullable String getSha1(); + + /** + * Send the latest resource pack to the given player, if they don't already have it. + */ + void refreshPlayer(Player player); + + /** + * Send the latest resource pack to all online players that don't already have it. + */ + void refreshAll(); +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/restart/RestartCommands.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/restart/RestartCommands.java new file mode 100644 index 0000000..c2f5cb7 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/restart/RestartCommands.java @@ -0,0 +1,96 @@ +package tc.oc.commons.bukkit.restart; + +import javax.inject.Inject; + +import com.sk89q.minecraft.util.commands.Command; +import com.sk89q.minecraft.util.commands.CommandContext; +import com.sk89q.minecraft.util.commands.CommandException; +import com.sk89q.minecraft.util.commands.CommandPermissions; +import com.sk89q.minecraft.util.commands.CommandPermissionsException; +import com.sk89q.minecraft.util.commands.Console; +import net.md_5.bungee.api.chat.TranslatableComponent; +import tc.oc.api.docs.virtual.ServerDoc; +import tc.oc.minecraft.scheduler.SyncExecutor; +import tc.oc.commons.core.chat.Audience; +import tc.oc.commons.core.chat.Audiences; +import tc.oc.commons.core.commands.CommandFutureCallback; +import tc.oc.commons.core.commands.Commands; +import tc.oc.commons.core.commands.TranslatableCommandException; +import tc.oc.commons.core.restart.RestartManager; +import tc.oc.minecraft.api.command.CommandSender; + +public class RestartCommands implements Commands { + + private final RestartManager restartManager; + private final SyncExecutor syncExecutor; + private final Audiences audiences; + + @Inject RestartCommands(RestartManager restartManager, SyncExecutor syncExecutor, Audiences audiences) { + this.restartManager = restartManager; + this.syncExecutor = syncExecutor; + this.audiences = audiences; + } + + @Command( + aliases = {"queuerestart", "qr"}, + desc = "Restart the server at the next safe opportunity", + usage = "[-l/h (low/high priority)]", + min = 0, + max = 0, + flags = "lh" + ) + @CommandPermissions("server.queuerestart") + @Console + public void queueRestart(CommandContext args, final CommandSender sender) throws CommandException { + final Audience audience = audiences.get(sender); + + final int priority; + if(args.hasFlag('l')) { + priority = ServerDoc.Restart.Priority.LOW; + } else if(args.hasFlag('h')) { + if(!sender.hasPermission("server.queuerestart.high")) { + throw new CommandPermissionsException(); + } + priority = ServerDoc.Restart.Priority.HIGH; + } else { + priority = ServerDoc.Restart.Priority.NORMAL; + } + + if(restartManager.isRestartRequested(priority)) { + audience.sendMessage(new TranslatableComponent("command.admin.queueRestart.restartQueued")); + return; + } + + syncExecutor.callback( + restartManager.requestRestart("/queuerestart command", priority), + CommandFutureCallback.onSuccess(sender, args, result -> { + if(restartManager.isRestartDeferred()) { + audience.sendMessage(new TranslatableComponent("command.admin.queueRestart.restartQueued")); + } else { + audience.sendMessage(new TranslatableComponent("command.admin.queueRestart.restartingNow")); + } + }) + ); + } + + @Command( + aliases = {"cancelrestart", "cr"}, + desc = "Cancels a previously requested restart", + min = 0, + max = 0 + ) + @CommandPermissions("server.cancelrestart") + @Console + public void cancelRestart(CommandContext args, final CommandSender sender) throws CommandException { + if(!restartManager.isRestartRequested()) { + throw new TranslatableCommandException("command.admin.cancelRestart.noActionTaken"); + } + + syncExecutor.callback( + restartManager.cancelRestart(), + CommandFutureCallback.onSuccess(sender, args, o -> { + audiences.get(sender).sendMessage(new TranslatableComponent("command.admin.cancelRestart.restartUnqueued")); + }) + ); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/sessions/SessionListener.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/sessions/SessionListener.java new file mode 100644 index 0000000..e470ea3 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/sessions/SessionListener.java @@ -0,0 +1,130 @@ +package tc.oc.commons.bukkit.sessions; + +import java.net.InetAddress; +import java.util.UUID; +import java.util.logging.Logger; +import javax.annotation.Nullable; +import javax.inject.Inject; +import javax.inject.Singleton; + +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerLoginEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import tc.oc.api.bukkit.users.BukkitUserStore; +import tc.oc.api.bukkit.users.OnlinePlayers; +import tc.oc.api.docs.Server; +import tc.oc.api.docs.Session; +import tc.oc.minecraft.scheduler.SyncExecutor; +import tc.oc.api.sessions.SessionService; +import tc.oc.api.sessions.SessionStartRequest; +import tc.oc.commons.bukkit.event.UserLoginEvent; +import tc.oc.commons.bukkit.nick.PlayerIdentityChangeEvent; +import tc.oc.commons.core.logging.Loggers; +import tc.oc.commons.core.plugin.PluginFacet; +import tc.oc.commons.core.util.SystemFutureCallback; + +/** + * Adds login sessions to the local cache + * Finishes sessions when players quit or login is denied + * Restarts sessions when players change nickname + */ +@Singleton +public class SessionListener implements Listener, PluginFacet { + + private final Logger logger; + private final SyncExecutor syncExecutor; + private final Server localServer; + private final SessionService sessionService; + private final OnlinePlayers onlinePlayers; + private final BukkitUserStore userStore; + + @Inject SessionListener(Loggers loggers, SyncExecutor syncExecutor, Server localServer, SessionService sessionService, OnlinePlayers onlinePlayers, BukkitUserStore userStore) { + this.logger = loggers.get(getClass()); + this.localServer = localServer; + this.onlinePlayers = onlinePlayers; + this.userStore = userStore; + this.syncExecutor = syncExecutor; + this.sessionService = sessionService; + } + + private void restartSession(final Player player) { + final UUID uuid = player.getUniqueId(); + final int entityId = player.getEntityId(); + syncExecutor.callback( + this.sessionService.start(new SessionStartRequest() { + @Override + public String server_id() { + return localServer._id(); + } + + @Override + public String player_id() { + return userStore.getUser(player).player_id(); + } + + @Override + public InetAddress ip() { + return player.getAddress().getAddress(); + } + + @Override + public @Nullable String previous_session_id() { + return userStore.session(player) + .map(Session::_id) + .orElse(null); + } + }), + new SystemFutureCallback() { + @Override + public void onSuccessThrows(Session session) throws Exception { + final Player player1 = onlinePlayers.find(uuid); + if(player1 != null && player1.getEntityId() == entityId) { + // If player is still online, store their session + userStore.setSession(player1, session); + logger.info("Start session " + session._id() + " for " + player1.getName() + " (identity change)"); + } else { + // If the player disconnected while we were starting their session, finish it right away + sessionService.finish(session); + logger.info("End session " + session._id() + " for " + player1.getName() + " (quick disconnect)"); + } + } + } + ); + } + + private void finishSession(Player player) { + Session session = userStore.removeSession(player); + if(session != null) { + this.sessionService.finish(session); + logger.info("End session " + session._id() + " for " + player.getName()); + } + } + + @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true) + public void onLoginEarly(UserLoginEvent event) { + if(event.getSession() != null) { + userStore.setSession(event.getPlayer(), event.getSession()); + logger.info("Start session " + event.getSession()._id() + " for " + event.getPlayer().getName() + " (login)"); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false) + public void onLoginLate(UserLoginEvent event) { + if(event.getResult() != PlayerLoginEvent.Result.ALLOWED) { + finishSession(event.getPlayer()); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onPlayerQuit(PlayerQuitEvent event) { + finishSession(event.getPlayer()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onNickChange(PlayerIdentityChangeEvent event) { + restartSession(event.getPlayer()); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/settings/RemoteTeleport.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/settings/RemoteTeleport.java new file mode 100644 index 0000000..b8345a5 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/settings/RemoteTeleport.java @@ -0,0 +1,20 @@ +package tc.oc.commons.bukkit.settings; + +import me.anxuiz.settings.Setting; +import me.anxuiz.settings.SettingBuilder; +import me.anxuiz.settings.types.BooleanType; + +/** + * TODO: Not implemented yet + */ +public class RemoteTeleport { + private static final Setting inst = new SettingBuilder() + .name("RemoteTeleport").alias("rtp") + .summary("Allow /tp to teleport you across servers, just like /rtp") + .type(new BooleanType()) + .defaultValue(true).get(); + + public static Setting get() { + return inst; + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/settings/SettingBinder.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/settings/SettingBinder.java new file mode 100644 index 0000000..ff2762e --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/settings/SettingBinder.java @@ -0,0 +1,14 @@ +package tc.oc.commons.bukkit.settings; + +import com.google.inject.Binder; +import me.anxuiz.settings.Setting; +import tc.oc.commons.core.inject.SetBinder; + +/** + * Used to register {@link Setting}s + */ +public class SettingBinder extends SetBinder { + public SettingBinder(Binder binder) { + super(binder); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/settings/SettingCallbackBinder.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/settings/SettingCallbackBinder.java new file mode 100644 index 0000000..b5c0a85 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/settings/SettingCallbackBinder.java @@ -0,0 +1,24 @@ +package tc.oc.commons.bukkit.settings; + +import com.google.inject.Binder; +import com.google.inject.binder.LinkedBindingBuilder; +import com.google.inject.multibindings.MapBinder; +import me.anxuiz.settings.Setting; +import me.anxuiz.settings.SettingCallback; + +/** + * Used to register {@link SettingCallback}s for specific {@link Setting}s + */ +public class SettingCallbackBinder { + + private final MapBinder mapBinder; + + public SettingCallbackBinder(Binder binder) { + mapBinder = MapBinder.newMapBinder(binder, Setting.class, SettingCallback.class) + .permitDuplicates(); + } + + public LinkedBindingBuilder changesIn(Setting setting) { + return mapBinder.addBinding(setting); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/settings/SettingManagerProvider.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/settings/SettingManagerProvider.java new file mode 100644 index 0000000..c833696 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/settings/SettingManagerProvider.java @@ -0,0 +1,32 @@ +package tc.oc.commons.bukkit.settings; + +import me.anxuiz.settings.SettingManager; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import tc.oc.api.docs.User; + +import java.util.Optional; + +/** + * Provides access to player settings for both online and offline players + */ +public interface SettingManagerProvider { + /** + * Return the {@link SettingManager} for an online player, + * which can be used to read and write settings. + */ + SettingManager getManager(Player player); + + default Optional tryManager(CommandSender sender) { + return Optional.ofNullable(sender instanceof Player ? getManager((Player) sender) : null); + } + + /** + * Return a {@link SettingManager} for the given {@link User}. + * + * If the user is currently online, their in-memory manager is returned. + * Otherwise, the a read-only manager is returned that extracts settings + * from the given {@link User} document. + */ + SettingManager getManager(User user); +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/settings/SettingManagerProviderImpl.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/settings/SettingManagerProviderImpl.java new file mode 100644 index 0000000..f296797 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/settings/SettingManagerProviderImpl.java @@ -0,0 +1,167 @@ +package tc.oc.commons.bukkit.settings; + +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import javax.inject.Inject; +import javax.inject.Singleton; + +import com.google.common.collect.ImmutableMap; +import me.anxuiz.settings.Setting; +import me.anxuiz.settings.SettingCallback; +import me.anxuiz.settings.SettingCallbackManager; +import me.anxuiz.settings.SettingManager; +import me.anxuiz.settings.SettingRegistry; +import me.anxuiz.settings.TypeParseException; +import me.anxuiz.settings.base.AbstractSettingManager; +import me.anxuiz.settings.bukkit.PlayerSettingCallback; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import tc.oc.api.bukkit.users.BukkitUserStore; +import tc.oc.api.bukkit.users.OnlinePlayers; +import tc.oc.api.docs.Server; +import tc.oc.api.docs.User; +import tc.oc.api.users.ChangeSettingRequest; +import tc.oc.api.users.UserService; +import tc.oc.commons.bukkit.event.UserLoginEvent; +import tc.oc.commons.core.plugin.PluginFacet; + +/** + * A singleton with several responsibilities related to player settings: + * + * - Provides access to settings through the {@link SettingManagerProvider} interface + * - Loads saved settings on player login + * - Listens for setting changes and saves them + * - Registers settings and callbacks that were bound at configuration time + */ +@Singleton +class SettingManagerProviderImpl implements SettingManagerProvider, Listener, PluginFacet { + + private final Server localServer; + private final BukkitUserStore userStore; + private final UserService userService; + private final OnlinePlayers onlinePlayers; + private final SettingRegistry settingRegistry; + + @Inject + SettingManagerProviderImpl(Server localServer, + SettingCallbackManager callbackManager, + BukkitUserStore userStore, + UserService userService, + OnlinePlayers onlinePlayers, + SettingRegistry settingRegistry, + Set settings, + Map callbacks) { + + this.localServer = localServer; + this.userStore = userStore; + this.userService = userService; + this.onlinePlayers = onlinePlayers; + this.settingRegistry = settingRegistry; + + callbackManager.addGlobalCallback(new Callback()); + + settings.forEach(settingRegistry::register); + callbacks.forEach(callbackManager::addCallback); + } + + @Override + public SettingManager getManager(Player player) { + return me.anxuiz.settings.bukkit.PlayerSettings.getManager(player); + } + + @Override + public SettingManager getManager(User user) { + final Player player = onlinePlayers.find(user); + return player != null ? getManager(player) + : new UserSettingManager(user); + } + + /** + * On login, copy settings from the player's {@link User} document + * to their PlayerSettingManager. + */ + @EventHandler(priority = EventPriority.LOW, ignoreCancelled = true) + public void loadSettings(UserLoginEvent event) { + new UserSettingManager(event.getUser()).copyTo(getManager(event.getPlayer())); + } + + /** + * Push setting changes to the API + */ + private class Callback extends PlayerSettingCallback { + @Override + public void notifyChange(@Nonnull Player player, @Nonnull Setting setting, @Nullable Object oldValue, @Nullable Object newValue) { + userService.changeSetting(userStore.getUser(player), new ChangeSettingRequest() { + @Override public String profile() { + return localServer.settings_profile(); + } + + @Override public String setting() { + return setting.getName(); + } + + @Override public String value() { + return newValue == null ? null : setting.getType().serialize(newValue); + } + }); + } + } + + /** + * Read settings directly from a {@link User} document. + * + * This is read-only, write methods will throw an exception. + */ + private class UserSettingManager extends AbstractSettingManager { + + final User user; + + UserSettingManager(User user) { + this.user = user; + } + + Map profile() { + return Optional.ofNullable(user.mc_settings_by_profile() + .get(localServer.settings_profile())) + .orElseGet(ImmutableMap::of); + } + + void copyTo(SettingManager that) { + final Map profile = profile(); + settingRegistry.getSettings().forEach(setting -> { + that.setValue(setting, getRawValue(setting, profile), false); + }); + } + + @Nullable Object getRawValue(Setting setting, Map profile) { + final String text = profile.get(setting.getName()); + if(text == null) return null; + + try { + return setting.getType().parse(text); + } catch(TypeParseException e) { + return null; + } + } + + @Override + public @Nullable Object getRawValue(Setting setting) { + return getRawValue(setting, profile()); + } + + @Override + protected void setRawValue(Setting setting, Object value) { + throw new UnsupportedOperationException(); + } + + @Override + public SettingCallbackManager getCallbackManager() { + throw new UnsupportedOperationException(); + } + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/settings/SettingManifest.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/settings/SettingManifest.java new file mode 100644 index 0000000..3198118 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/settings/SettingManifest.java @@ -0,0 +1,50 @@ +package tc.oc.commons.bukkit.settings; + +import com.google.inject.Provides; +import me.anxuiz.settings.SettingCallbackManager; +import me.anxuiz.settings.SettingRegistry; +import me.anxuiz.settings.bukkit.PlayerSettings; +import tc.oc.commons.core.inject.HybridManifest; +import tc.oc.commons.core.inject.SingletonManifest; +import tc.oc.commons.core.plugin.PluginFacetBinder; + +/** + * Configures the player settings system + * + * Provides the following service classes: + * + * {@link SettingManagerProvider} Access settings for specific players + * {@link SettingRegistry} Register and lookup setting definitions + * {@link SettingCallbackManager} Register setting change callbacks + * + * Also allows settings and callbacks to be registered at configuration time + * using {@link SettingBinder} and {@link SettingCallbackBinder}. + */ +public class SettingManifest extends HybridManifest { + public static class Public extends SingletonManifest { + @Override + protected void configure() { + new SettingBinder(binder()); + new SettingCallbackBinder(binder()); + } + + @Provides + SettingRegistry settingRegistry() { + return PlayerSettings.getRegistry(); + } + + @Provides + SettingCallbackManager settingCallbackManager() { + return PlayerSettings.getCallbackManager(); + } + } + + @Override + protected void configure() { + publicBinder().install(new Public()); + + bind(SettingManagerProviderImpl.class); + bindAndExpose(SettingManagerProvider.class).to(SettingManagerProviderImpl.class); + new PluginFacetBinder(binder()).add(SettingManagerProviderImpl.class); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/tablist/BlankTabEntry.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/tablist/BlankTabEntry.java new file mode 100644 index 0000000..3f0b9d7 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/tablist/BlankTabEntry.java @@ -0,0 +1,20 @@ +package tc.oc.commons.bukkit.tablist; + +import net.md_5.bungee.api.chat.TextComponent; +import tc.oc.commons.core.util.DefaultProvider; + +public class BlankTabEntry extends StaticTabEntry { + + public static class Factory implements DefaultProvider { + @Override + public TabEntry get(Integer key) { + return new BlankTabEntry(); + } + } + + private final static TextComponent BLANK_COMPONENT = new TextComponent(""); + + public BlankTabEntry() { + super(BLANK_COMPONENT); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/tablist/DynamicTabEntry.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/tablist/DynamicTabEntry.java new file mode 100644 index 0000000..9005976 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/tablist/DynamicTabEntry.java @@ -0,0 +1,83 @@ +package tc.oc.commons.bukkit.tablist; + +import com.google.common.collect.Iterables; + +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; + +/** + * Adds dirty tracking of {@link TabView}s. + */ +public abstract class DynamicTabEntry extends SimpleTabEntry { + final Set cleanViews = new HashSet<>(); + final Set dirtyViews = new HashSet<>(); + + public DynamicTabEntry(UUID uuid) { + super(uuid); + } + + public DynamicTabEntry() { + } + + /** + * Mark all {@link TabView}s containing this entry dirty + */ + public void invalidate() { + if(cleanViews.isEmpty()) return; + + for(TabView view : cleanViews) { + view.invalidateContent(this); + } + + dirtyViews.addAll(cleanViews); + cleanViews.clear(); + } + + @Override + public boolean isDirty(TabView view) { + return dirtyViews.contains(view); + } + + @Override + public void markClean(TabView view) { + cleanViews.add(view); + dirtyViews.remove(view); + } + + @Override + public void addToView(TabView view) { + dirtyViews.add(view); + } + + @Override + public void removeFromView(TabView view) { + cleanViews.remove(view); + dirtyViews.remove(view); + } + + public boolean hasViews() { + return !(dirtyViews.isEmpty() && cleanViews.isEmpty()); + } + + public Iterable getViews() { + return Iterables.concat(cleanViews, dirtyViews); + } + + /** + * Re-adds this entry to all {@link TabView}s that contain it, + * which has the effect of updating its skin. + */ + public void refresh() { + for(TabView view : getViews()) view.refreshEntry(this); + } + + /** + * Updates the metadata of the fake entity for this entry for all + * {@link TabView}s, which has the effect of updating the state + * of the hat layer on the skin. + */ + public void updateFakeEntity() { + for(TabView view : getViews()) view.updateFakeEntity(this); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/tablist/PlayerTabEntry.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/tablist/PlayerTabEntry.java new file mode 100644 index 0000000..b568d4f --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/tablist/PlayerTabEntry.java @@ -0,0 +1,105 @@ +package tc.oc.commons.bukkit.tablist; + +import java.util.UUID; +import javax.annotation.Nullable; +import javax.inject.Inject; + +import net.md_5.bungee.api.chat.BaseComponent; +import org.bukkit.Skin; +import org.bukkit.entity.Player; +import org.bukkit.event.player.PlayerSkinPartsChangeEvent; +import tc.oc.commons.bukkit.chat.NameStyle; +import tc.oc.commons.bukkit.chat.PlayerComponent; +import tc.oc.commons.bukkit.nick.Identity; +import tc.oc.commons.bukkit.nick.IdentityProvider; +import tc.oc.commons.bukkit.nick.PlayerIdentityChangeEvent; +import tc.oc.commons.core.util.DefaultProvider; + +/** + * {@link TabEntry} showing a {@link Player}'s name and skin. + * + * Note that this is NOT the player's real entry. It has a random UUID and name, + * like any other {@link SimpleTabEntry}. While this entry is visible in a {@link TabView}, + * a fake player entity will be spawned with a copy of the real player's metadata. + */ +public class PlayerTabEntry extends DynamicTabEntry { + public static class Factory implements DefaultProvider { + @Override + public PlayerTabEntry get(Player key) { + return new PlayerTabEntry(key); + } + } + + private static UUID randomUUIDVersion2SameDefaultSkin(UUID original) { + // Parity of UUID.hashCode determines if the player's default skin is Steve/Alex + // To make the player list match, we generate a random UUID with the same hashCode parity. + // UUID.hashCode returns the XOR of its four 32-bit segments, so set bit 0 to the desired + // parity, and clear bits 32, 64, and 96 + + long parity = original.hashCode() & 1L; + long mask = ~((1L << 32) | 1L); + UUID uuid = randomUUIDVersion2(); + uuid = new UUID(uuid.getMostSignificantBits() & mask, (uuid.getLeastSignificantBits() & mask) | parity); + return uuid; + } + + @Inject private static IdentityProvider identityProvider; + + protected final Player player; + protected @Nullable PlayerComponent content; + private final int spareEntityId; + + public PlayerTabEntry(Player player) { + super(randomUUIDVersion2SameDefaultSkin(player.getUniqueId())); + this.player = player; + this.spareEntityId = player.getServer().allocateEntityId(); + } + + @Override + public BaseComponent getContent(TabView view) { + if(content == null) { + this.content = new PlayerComponent(identityProvider.currentIdentity(player), NameStyle.GAME); + } + return content; + } + + @Override + public int getFakeEntityId(TabView view) { + return this.spareEntityId; + } + + @Override + public Player getFakePlayer(TabView view) { + return this.player; + } + + @Override + public @Nullable Skin getSkin(TabView view) { + final Identity identity = identityProvider.currentIdentity(player); + return identity.isDisguised(view.getViewer()) ? null : player.getSkin(); + } + + // Dispatched by TabManager + protected void onNickChange(PlayerIdentityChangeEvent event) { + if(this.player == event.getPlayer()) { + // PlayerComponents are bound to an Identity, and we always want to show + // the player's current Identity, so we have to replace the PlayerComponent + // when the player's identity changes. + this.content = null; + this.invalidate(); + this.refresh(); + } + } + + // Dispatched by TabManager + protected void onSkinPartsChange(PlayerSkinPartsChangeEvent event) { + if(this.player == event.getPlayer()) { + this.updateFakeEntity(); + } + } + + @Override + public String toString() { + return this.getClass().getSimpleName() + "{" + this.player.getName() + "}"; + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/tablist/SimpleTabEntry.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/tablist/SimpleTabEntry.java new file mode 100644 index 0000000..9fbcb48 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/tablist/SimpleTabEntry.java @@ -0,0 +1,73 @@ +package tc.oc.commons.bukkit.tablist; + +import org.bukkit.GameMode; +import org.bukkit.Skin; +import org.bukkit.entity.Player; + +import javax.annotation.Nullable; +import java.util.UUID; + +/** + * Implements part of {@link TabEntry} with a few generally useful properties + */ +public abstract class SimpleTabEntry implements TabEntry { + // Dark grey square + private static final Skin DEFAULT_SKIN = new Skin( + "eyJ0aW1lc3RhbXAiOjE0MTEyNjg3OTI3NjUsInByb2ZpbGVJZCI6IjNmYmVjN2RkMGE1ZjQwYmY5ZDExODg1YTU0NTA3MTEyIiwicHJvZmlsZU5hbWUiOiJsYXN0X3VzZXJuYW1lIiwidGV4dHVyZXMiOnsiU0tJTiI6eyJ1cmwiOiJodHRwOi8vdGV4dHVyZXMubWluZWNyYWZ0Lm5ldC90ZXh0dXJlLzg0N2I1Mjc5OTg0NjUxNTRhZDZjMjM4YTFlM2MyZGQzZTMyOTY1MzUyZTNhNjRmMzZlMTZhOTQwNWFiOCJ9fX0=", + "u8sG8tlbmiekrfAdQjy4nXIcCfNdnUZzXSx9BE1X5K27NiUvE1dDNIeBBSPdZzQG1kHGijuokuHPdNi/KXHZkQM7OJ4aCu5JiUoOY28uz3wZhW4D+KG3dH4ei5ww2KwvjcqVL7LFKfr/ONU5Hvi7MIIty1eKpoGDYpWj3WjnbN4ye5Zo88I2ZEkP1wBw2eDDN4P3YEDYTumQndcbXFPuRRTntoGdZq3N5EBKfDZxlw4L3pgkcSLU5rWkd5UH4ZUOHAP/VaJ04mpFLsFXzzdU4xNZ5fthCwxwVBNLtHRWO26k/qcVBzvEXtKGFJmxfLGCzXScET/OjUBak/JEkkRG2m+kpmBMgFRNtjyZgQ1w08U6HHnLTiAiio3JswPlW5v56pGWRHQT5XWSkfnrXDalxtSmPnB5LmacpIImKgL8V9wLnWvBzI7SHjlyQbbgd+kUOkLlu7+717ySDEJwsFJekfuR6N/rpcYgNZYrxDwe4w57uDPlwNL6cJPfNUHV7WEbIU1pMgxsxaXe8WSvV87qLsR7H06xocl2C0JFfe2jZR4Zh3k9xzEnfCeFKBgGb4lrOWBu1eDWYgtKV67M2Y+B3W5pjuAjwAxn0waODtEn/3jKPbc/sxbPvljUCw65X+ok0UUN1eOwXV5l2EGzn05t3Yhwq19/GxARg63ISGE8CKw=" + ); + + protected static UUID randomUUIDVersion2() { + UUID uuid = UUID.randomUUID(); + return new UUID((uuid.getMostSignificantBits() & ~0xf000) | 0x2000, uuid.getLeastSignificantBits()); + } + + private final UUID uuid; + private final String name; + + protected SimpleTabEntry(UUID uuid) { + this.uuid = uuid; + + String name = this.uuid.toString(); + this.name = name.substring(name.length() - 16); // Use last 16 chars, most likely to be unique + } + + protected SimpleTabEntry() { + this(randomUUIDVersion2()); + } + + @Override + public UUID getId() { + return this.uuid; + } + + @Override + public String getName(TabView view) { + return this.name; + } + + @Override + public GameMode getGamemode() { + return GameMode.CREATIVE; + } + + @Override + public int getPing() { + return 9999; + } + + @Override + public @Nullable Skin getSkin(TabView view) { + return DEFAULT_SKIN; + } + + @Override + public @Nullable Player getFakePlayer(TabView view) { + return null; + } + + @Override + public int getFakeEntityId(TabView view) { + return -1; + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/tablist/StaticTabEntry.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/tablist/StaticTabEntry.java new file mode 100644 index 0000000..5f89547 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/tablist/StaticTabEntry.java @@ -0,0 +1,34 @@ +package tc.oc.commons.bukkit.tablist; + +import net.md_5.bungee.api.chat.BaseComponent; + +public class StaticTabEntry extends SimpleTabEntry { + + private final BaseComponent content; + + public StaticTabEntry(BaseComponent content) { + this.content = content; + } + + @Override + public void addToView(TabView view) { + } + + @Override + public void removeFromView(TabView view) { + } + + @Override + public boolean isDirty(TabView view) { + return false; + } + + @Override + public void markClean(TabView view) { + } + + @Override + public BaseComponent getContent(TabView view) { + return content; + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/tablist/TabEntry.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/tablist/TabEntry.java new file mode 100644 index 0000000..5a42965 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/tablist/TabEntry.java @@ -0,0 +1,80 @@ +package tc.oc.commons.bukkit.tablist; + +import net.md_5.bungee.api.chat.BaseComponent; +import org.bukkit.GameMode; +import org.bukkit.Skin; +import org.bukkit.entity.Player; + +import javax.annotation.Nullable; +import java.util.UUID; + +/** + * Content for a slot in a {@link TabView} + */ +public interface TabEntry { + + /** + * Called by {@link TabView}s when this entry is added to the view + */ + void addToView(TabView view); + + /** + * Called by {@link TabView}s when this entry is removed from the view + */ + void removeFromView(TabView view); + + /** + * Called by {@link TabView} during rendering to decide if this entry needs to be rendered + */ + boolean isDirty(TabView view); + + /** + * Called by {@link TabView} after rendering this entry in the view + */ + void markClean(TabView view); + + /** + * UUID for the entry. The client's player list is keyed on this, so it must be unique. + * If it matches a real player's UUID, this entry will affect the player in all sorts of ways. + */ + UUID getId(); + + /** + * {@link Player} represented by this entry, or null if it does not represent a player. + * The renderer will use this player's metadata to spawn a fake player so that the + * hat layer on the entry's icon can be controlled. + * + * See {@link PlayerTabEntry} for more info. + */ + @Nullable Player getFakePlayer(TabView view); + + /** + * Entity ID of the fake player mentioned above. If used, this must not collide with any real entites. + */ + int getFakeEntityId(TabView view); + + /** + * Name for the entry (not visible) + */ + String getName(TabView view); + + /** + * Content to show in the entry + */ + BaseComponent getContent(TabView view); + + /** + * Gamemode for the entry. If the entry is linked to a real player, this will change the client's gamemode. + */ + GameMode getGamemode(); + + /** + * Ping value for the entry + */ + int getPing(); + + /** + * Skin for the entry's icon + */ + @Nullable Skin getSkin(TabView view); +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/tablist/TabManager.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/tablist/TabManager.java new file mode 100644 index 0000000..d6098e8 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/tablist/TabManager.java @@ -0,0 +1,159 @@ +package tc.oc.commons.bukkit.tablist; + +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerChangedWorldEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.event.player.PlayerRespawnEvent; +import org.bukkit.event.player.PlayerSkinPartsChangeEvent; +import org.bukkit.plugin.Plugin; +import tc.oc.commons.bukkit.nick.PlayerIdentityChangeEvent; +import tc.oc.commons.core.logging.ClassLogger; +import tc.oc.commons.core.util.DefaultMapAdapter; +import tc.oc.commons.core.util.DefaultProvider; + +import javax.annotation.Nullable; +import java.util.Map; +import java.util.logging.Logger; + +/** + * Custom player list display (for 1.8 and later) + * + * Class Overview: + * TabManager God object that connects everything together, should only need one of these + * TabView A single player's custom tab list + * TabEntry Abstract base for a piece of content that goes in a tab list slot + * GenericTabEntry Adds a few more generic things to TabEntry + * TextTabEntry TabEntry containing arbitrary text content + * PlayerTabEntry TabEntry showing a player's name and skin + * BlankTabEntry A pool of these is used to fill in empty slots + * TabRender Instantiated for each render, contains all the hacky stuff + * + * A single TabEntry can be part of multiple TabViews simultaneously, and can show different content + * for each view. The idea is that TabEntry subclasses can be quite smart and generate their content + * dynamically. They have a dirty flag, so the content is not generated unless the TabEntry has been + * invalidated. They can also respond to events by invalidating themselves, which is automatically + * propagated to any TabViews that contain them. + * + * Rendering is deferred and always happens through the TabManager.render() method. This will check all + * views for dirtiness and render them. It is left to subclasses to call this method, so they can + * render whenever they want, potentially deferring it for efficiency. However, all views must be + * rendered together. It is not possible to render views individually, because this would make the + * TabEntry dirty state very difficult to track. + */ +public class TabManager implements Listener { + protected final Logger logger; + protected final Plugin plugin; + final DefaultMapAdapter enabledViews; + + final DefaultMapAdapter playerEntries; + final Map blankEntries = new DefaultMapAdapter(new BlankTabEntry.Factory(), true); + + boolean dirty; + + public TabManager(Plugin plugin, + @Nullable DefaultProvider viewProvider, + @Nullable DefaultProvider playerEntryProvider) { + + if(viewProvider == null) viewProvider = new TabView.Factory(); + if(playerEntryProvider == null) playerEntryProvider = new PlayerTabEntry.Factory(); + + this.logger = new ClassLogger(plugin.getLogger(), getClass()); + this.plugin = plugin; + this.enabledViews = new DefaultMapAdapter<>(viewProvider, true); + this.playerEntries = new DefaultMapAdapter<>(playerEntryProvider, true); + } + + public TabManager(Plugin plugin) { + this(plugin, null, null); + } + + public Plugin getPlugin() { + return plugin; + } + + public @Nullable TabView getViewOrNull(Player viewer) { + return this.enabledViews.getOrNull(viewer); + } + + public @Nullable TabView getView(Player viewer) { + return this.enabledViews.get(viewer); + } + + protected void removeView(TabView view) { + if(this.enabledViews.remove(view.getViewer()) != null) view.disable(); + } + + public @Nullable TabEntry getPlayerEntryOrNull(Player player) { + return this.playerEntries.getOrNull(player); + } + + public TabEntry getPlayerEntry(Player player) { + if(!player.willBeOnline()) { + throw new IllegalStateException("Tried to get TabEntry for disconnecting player"); + } + return this.playerEntries.get(player); + } + + public @Nullable TabEntry removePlayerEntry(Player player) { + return this.playerEntries.remove(player); + } + + protected TabEntry getBlankEntry(int index) { + return this.blankEntries.get(index); + } + + protected void invalidate() { + this.dirty = true; + } + + public void render() { + if(this.dirty) { + for(TabView view : this.enabledViews.values()) { + view.render(); + } + + this.dirty = false; + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onQuit(PlayerQuitEvent event) { + TabView view = this.getViewOrNull(event.getPlayer()); + if(view != null) { + view.disable(); + this.removeView(view); + } + this.removePlayerEntry(event.getPlayer()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onRespawn(PlayerRespawnEvent event) { + TabView view = this.getViewOrNull(event.getPlayer()); + if(view != null) view.onRespawn(event); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onWorldChange(PlayerChangedWorldEvent event) { + TabView view = this.getViewOrNull(event.getPlayer()); + if(view != null) view.onWorldChange(event); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onNickChange(PlayerIdentityChangeEvent event) { + TabEntry entry = this.getPlayerEntryOrNull(event.getPlayer()); + if(entry instanceof PlayerTabEntry) { + ((PlayerTabEntry) entry).onNickChange(event); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onSkinPartsChange(PlayerSkinPartsChangeEvent event) { + TabEntry entry = this.getPlayerEntryOrNull(event.getPlayer()); + if(entry instanceof PlayerTabEntry) { + ((PlayerTabEntry) entry).onSkinPartsChange(event); + } + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/tablist/TabRender.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/tablist/TabRender.java new file mode 100644 index 0000000..9e86352 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/tablist/TabRender.java @@ -0,0 +1,152 @@ +package tc.oc.commons.bukkit.tablist; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import javax.inject.Inject; + +import net.md_5.bungee.api.chat.BaseComponent; +import net.minecraft.server.Packet; +import net.minecraft.server.PacketPlayOutPlayerInfo; +import org.bukkit.Location; +import org.bukkit.entity.Player; +import tc.oc.commons.bukkit.chat.ComponentRenderContext; +import tc.oc.commons.bukkit.util.NMSHacks; + +public class TabRender { + + @Inject private static ComponentRenderContext componentRenderContext; + + private final TabView view; + + private final PacketPlayOutPlayerInfo removePacket; + private final PacketPlayOutPlayerInfo addPacket; + private final PacketPlayOutPlayerInfo updatePacket; + private final List deferredPackets; + + public TabRender(TabView view) { + this.view = view; + this.removePacket = this.createPlayerInfoPacket(PacketPlayOutPlayerInfo.EnumPlayerInfoAction.REMOVE_PLAYER); + this.addPacket = this.createPlayerInfoPacket(PacketPlayOutPlayerInfo.EnumPlayerInfoAction.ADD_PLAYER); + this.updatePacket = this.createPlayerInfoPacket(PacketPlayOutPlayerInfo.EnumPlayerInfoAction.UPDATE_DISPLAY_NAME); + this.deferredPackets = new ArrayList<>(); + } + + private String teamName(int slot) { + return "\u0001TabView" + String.format("%03d", slot); + } + + private void send(Packet packet) { + NMSHacks.sendPacket(this.view.getViewer(), packet); + } + + private PacketPlayOutPlayerInfo createPlayerInfoPacket(PacketPlayOutPlayerInfo.EnumPlayerInfoAction action) { + return new PacketPlayOutPlayerInfo(action); + } + + private BaseComponent getContent(TabEntry entry, int index) { + return this.componentRenderContext.render(entry.getContent(this.view), this.view.getViewer()); + } + + private void appendAddition(TabEntry entry, int index) { + BaseComponent displayName = this.getContent(entry, index); + this.addPacket.add(NMSHacks.playerListPacketData(this.addPacket, + entry.getId(), + entry.getName(this.view), + displayName, + entry.getGamemode(), + entry.getPing(), + entry.getSkin(this.view))); + + // Due to a client bug, display name is ignored in ADD_PLAYER packets, + // so we have to send an UPDATE_DISPLAY_NAME afterward. + this.updatePacket.add(NMSHacks.playerListPacketData(this.updatePacket, entry.getId(), displayName)); + + this.updateFakeEntity(entry, true); + } + + private void appendRemoval(TabEntry entry) { + this.removePacket.add(NMSHacks.playerListPacketData(this.removePacket, entry.getId())); + + int entityId = entry.getFakeEntityId(this.view); + if(entityId >= 0) { + this.send(NMSHacks.destroyEntitiesPacket(entityId)); + } + } + + private void leaveSlot(TabEntry entry, int index) { + this.send(NMSHacks.teamLeavePacket(this.teamName(index), Collections.singleton(entry.getName(this.view)))); + } + + private void joinSlot(TabEntry entry, int index) { + this.send(NMSHacks.teamJoinPacket(this.teamName(index), Collections.singleton(entry.getName(this.view)))); + } + + public void finish() { + if(!this.removePacket.isEmpty()) this.send(this.removePacket); + if(!this.addPacket.isEmpty()) this.send(this.addPacket); + if(!this.updatePacket.isEmpty()) this.send(this.updatePacket); + + for(Packet packet : this.deferredPackets) { + this.send(packet); + } + } + + public void changeSlot(TabEntry entry, int oldIndex, int newIndex) { + Collection names = Collections.singleton(entry.getName(this.view)); + this.send(NMSHacks.teamJoinPacket(this.teamName(newIndex), names)); + } + + public void createSlot(TabEntry entry, int index) { + String teamName = this.teamName(index); + this.send(NMSHacks.teamCreatePacket(teamName, teamName, "", "", false, false, Collections.singleton(entry.getName(this.view)))); + this.appendAddition(entry, index); + } + + public void destroySlot(TabEntry entry, int index) { + this.send(NMSHacks.teamRemovePacket(this.teamName(index))); + this.appendRemoval(entry); + } + + public void addEntry(TabEntry entry, int index) { + this.joinSlot(entry, index); + this.appendAddition(entry, index); + } + + public void removeEntry(TabEntry entry, int index) { + this.leaveSlot(entry, index); + this.appendRemoval(entry); + } + + public void refreshEntry(TabEntry entry, int index) { + this.appendRemoval(entry); + this.appendAddition(entry, index); + } + + public void updateEntry(TabEntry entry, int index) { + this.updatePacket.add(NMSHacks.playerListPacketData(this.updatePacket, entry.getId(), this.getContent(entry, index))); + } + + public void setHeaderFooter(TabEntry header, TabEntry footer) { + view.getViewer().setPlayerListHeaderFooter(componentRenderContext.render(header.getContent(view), view.getViewer()), + componentRenderContext.render(footer.getContent(view), view.getViewer())); + } + + public void updateFakeEntity(TabEntry entry, boolean create) { + Player player = entry.getFakePlayer(this.view); + if(player != null) { + int entityId = entry.getFakeEntityId(this.view); + if(create) { + this.deferredPackets.add(NMSHacks.spawnPlayerPacket( + entityId, + entry.getId(), + new Location(this.view.getViewer().getWorld(), 0, Integer.MAX_VALUE / 2, 0, 0, 0), + player + )); + } else { + this.deferredPackets.add(NMSHacks.entityMetadataPacket(entityId, player, true)); + } + } + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/tablist/TabView.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/tablist/TabView.java new file mode 100644 index 0000000..828a4a7 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/tablist/TabView.java @@ -0,0 +1,384 @@ +package tc.oc.commons.bukkit.tablist; + +import java.util.HashMap; +import java.util.Map; + +import org.bukkit.entity.Player; +import org.bukkit.event.player.PlayerChangedWorldEvent; +import org.bukkit.event.player.PlayerRespawnEvent; +import org.bukkit.scheduler.BukkitTask; +import tc.oc.commons.core.util.DefaultProvider; + +import javax.annotation.Nullable; + +/** + * A single player's tab list. When this view is enabled, it creates a scoreboard team for + * each slot and creates an entry for each team. The team names are used to order the list. + * The view is always full of entries. When an entry is removed, it is replaced by a blank + * one. The player's list is not disabled, but because it is always full of fake entries, + * the real entries are pushed off the bottom and cannot be seen. The fake team names all + * start with a '\u0001' character, so they will always come before any real teams. + */ +public class TabView { + + public static class Factory implements DefaultProvider { + @Override + public TabView get(Player key) { + return new TabView(key); + } + } + + private static final int WIDTH = 4, HEIGHT = 20; + + private final int size, headerSlot, footerSlot; + + // The single player seeing this view + private final Player viewer; + + protected @Nullable TabManager manager; + + // True when any slots/header/footer have been changed but not rendered + private boolean dirtyLayout, dirtyContent, dirtyHeaderFooter; + + private final TabEntry[] slots, rendered; + + private @Nullable BukkitTask fakeEntityTask; + + public TabView(Player viewer) { + this.viewer = viewer; + this.size = WIDTH * HEIGHT; + this.headerSlot = this.size; + this.footerSlot = this.headerSlot + 1; + + // Two extra slots for header/footer + this.slots = new TabEntry[this.size + 2]; + this.rendered = new TabEntry[this.size + 2]; + } + + private void assertEnabled() { + if(manager == null) throw new IllegalStateException(getClass().getSimpleName() + " is not enabled"); + } + + public Player getViewer() { + return viewer; + } + + public int getWidth() { + return WIDTH; + } + + public int getHeight() { + return HEIGHT; + } + + public int getSize() { + return this.size; + } + + /** + * Take control of the viewer's player list + */ + public void enable(TabManager manager) { + if(this.manager != null) disable(); + this.manager = manager; + this.setup(); + + this.invalidateLayout(); + this.invalidateContent(); + this.invalidateHeaderFooter(); + } + + /** + * Tear down the display and return control the the viewer's player list to bukkit + */ + public void disable() { + if(this.manager != null) { + if(fakeEntityTask != null) { + fakeEntityTask.cancel(); + fakeEntityTask = null; + } + + this.manager.removeView(this); + this.tearDown(); + + for(int i = 0; i < slots.length; i++) { + if(slots[i] != null) { + slots[i].removeFromView(this); + slots[i] = null; + } + } + + this.manager = null; + } + } + + private void invalidateManager() { + if(this.manager != null) this.manager.invalidate(); + } + + protected void invalidateLayout() { + if(!this.dirtyLayout) { + this.dirtyLayout = true; + this.invalidateManager(); + } + } + + protected void invalidateContent() { + if(!this.dirtyContent) { + this.dirtyContent = true; + this.invalidateManager(); + } + } + + protected void invalidateLayoutAndContent() { + if(!dirtyLayout || !dirtyContent) { + dirtyLayout = dirtyContent = true; + invalidateManager(); + } + } + + protected void invalidateContent(TabEntry entry) { + int slot = getSlot(entry); + if(slot >= this.size) { + this.invalidateHeaderFooter(); + } else if(slot >= 0) { + this.invalidateContent(); + } + } + + protected void invalidateHeaderFooter() { + if(!this.dirtyHeaderFooter) { + this.dirtyHeaderFooter = true; + this.invalidateManager(); + } + } + + protected boolean isLayoutDirty() { + return this.dirtyLayout; + } + + protected int getSlot(TabEntry entry) { + for(int i = 0; i < slots.length; i++) { + if(entry == slots[i]) return i; + } + return -1; + } + + private void setSlot(int slot, @Nullable TabEntry entry) { + assertEnabled(); + + if(entry == null) { + entry = this.manager.getBlankEntry(slot); + } + + TabEntry oldEntry = this.slots[slot]; + if(oldEntry != entry) { + oldEntry.removeFromView(this); + + int oldIndex = getSlot(entry); + + if(oldIndex != -1) { + TabEntry blankEntry = this.manager.getBlankEntry(oldIndex); + this.slots[oldIndex] = blankEntry; + blankEntry.addToView(this); + } else { + entry.addToView(this); + } + + this.slots[slot] = entry; + + if(slot < this.size) { + this.invalidateLayoutAndContent(); + } else { + this.invalidateHeaderFooter(); + } + } + } + + public void setSlot(int x, int y, @Nullable TabEntry entry) { + this.setSlot(this.slotIndex(x, y), entry); + } + + public void setHeader(@Nullable TabEntry entry) { + this.setSlot(this.headerSlot, entry); + } + + public void setFooter(@Nullable TabEntry entry) { + this.setSlot(this.footerSlot, entry); + } + + private int slotIndex(int x, int y) { + return x * HEIGHT + y; + } + + public void render() { + if(this.manager == null) return; + + TabRender render = new TabRender(this); + this.renderLayout(render); + this.renderContent(render); + this.markSlotsClean(); + this.renderHeaderFooter(render, false); + render.finish(); + } + + public void renderLayout(TabRender render) { + if(this.manager == null) return; + + if(this.dirtyLayout) { + this.dirtyLayout = false; + + // First search for entries that have been added, removed, or moved + Map removals = new HashMap<>(); + Map additions = new HashMap<>(); + + for(int index = 0; index < this.size; index++) { + TabEntry oldEntry = this.rendered[index]; + TabEntry newEntry = this.rendered[index] = this.slots[index]; + + if(oldEntry != newEntry) { + // There is a different entry in this slot + + Integer oldIndex = removals.remove(newEntry); + if(oldIndex == null) { + // We have not seen the new entry yet, so assume it's being added + additions.put(newEntry, index); + } else { + // We already saw the new entry removed from another slot, so it's actually being moved + render.changeSlot(newEntry, oldIndex, index); + } + + Integer newIndex = additions.remove(oldEntry); + if(newIndex == null) { + // We have not seen the old entry yet, so assume it's being removed + removals.put(oldEntry, index); + } else { + // We already saw the old entry added to another slot, so it's actually being moved + render.changeSlot(oldEntry, index, newIndex); + } + } + + } + + // Build the removal packet + for(Map.Entry removal : removals.entrySet()) { + render.removeEntry(removal.getKey(), removal.getValue()); + } + + // Build the addition packet (this also adds to the update packet) + for(Map.Entry addition : additions.entrySet()) { + render.addEntry(addition.getKey(), addition.getValue()); + } + } + } + + public void renderContent(TabRender render) { + if(this.manager == null) return; + + if(this.dirtyContent) { + this.dirtyContent = false; + + // Build the update packet from entries with new content that are not being added or removed + for(int i = 0; i < this.size; i++) { + if(this.slots[i].isDirty(this)) { + render.updateEntry(this.slots[i], i); + } + } + } + } + + public void markSlotsClean() { + for(TabEntry entry : slots) { + entry.markClean(this); + } + } + + public void renderHeaderFooter(TabRender render, boolean force) { + if(this.manager == null) return; + + if(force || this.dirtyHeaderFooter) { + this.dirtyHeaderFooter = false; + render.setHeaderFooter(this.rendered[this.headerSlot] = this.slots[this.headerSlot], + this.rendered[this.footerSlot] = this.slots[this.footerSlot]); + this.slots[this.headerSlot].markClean(this); + this.slots[this.footerSlot].markClean(this); + } + } + + private void setup() { + assertEnabled(); + + for(int slot = 0; slot < this.slots.length; slot++) { + this.slots[slot] = this.manager.getBlankEntry(slot); + this.slots[slot].addToView(this); + } + + TabRender render = new TabRender(this); + + for(int index = 0; index < this.size; index++) { + render.createSlot(this.rendered[index] = this.slots[index], index); + } + + this.renderHeaderFooter(render, true); + + render.finish(); + } + + private void tearDown() { + if(this.manager == null) return; + + TabRender render = new TabRender(this); + + render.setHeaderFooter(this.manager.getBlankEntry(this.headerSlot), + this.manager.getBlankEntry(this.footerSlot)); + + for(int index = 0; index < this.size; index++) { + render.destroySlot(this.rendered[index], index); + this.rendered[index] = null; + } + + render.finish(); + } + + protected void refreshEntry(TabEntry entry) { + if(this.manager == null) return; + + TabRender render = new TabRender(this); + int slot = getSlot(entry); + if(slot < this.size) { + render.refreshEntry(entry, slot); + } else { + this.renderHeaderFooter(render, true); + } + render.finish(); + } + + protected void updateFakeEntity(TabEntry entry) { + if(this.manager == null) return; + + TabRender render = new TabRender(this); + render.updateFakeEntity(entry, false); + render.finish(); + } + + private void respawnFakeEntities() { + if(this.manager == null || fakeEntityTask != null) return; + + fakeEntityTask = this.viewer.getServer().getScheduler().runTask(this.manager.getPlugin(), () -> { + fakeEntityTask = null; + TabRender render = new TabRender(TabView.this); + for(TabEntry entry : TabView.this.rendered) { + render.updateFakeEntity(entry, true); + } + render.finish(); + }); + } + + protected void onRespawn(PlayerRespawnEvent event) { + if(viewer.equals(event.getPlayer())) this.respawnFakeEntities(); + } + + protected void onWorldChange(PlayerChangedWorldEvent event) { + if(viewer.equals(event.getPlayer())) this.respawnFakeEntities(); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/teleport/FeaturedServerTracker.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/teleport/FeaturedServerTracker.java new file mode 100644 index 0000000..cbdfcf3 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/teleport/FeaturedServerTracker.java @@ -0,0 +1,101 @@ +package tc.oc.commons.bukkit.teleport; + +import java.util.Comparator; +import java.util.HashMap; +import java.util.Map; +import javax.annotation.Nullable; +import javax.inject.Inject; +import javax.inject.Singleton; + +import org.bukkit.configuration.Configuration; +import tc.oc.api.docs.Server; +import tc.oc.api.docs.virtual.ServerDoc; +import tc.oc.api.model.ModelDispatcher; +import tc.oc.api.model.ModelListener; +import tc.oc.api.servers.ServerStore; +import tc.oc.commons.core.plugin.PluginFacet; + +import static java.util.Comparator.comparing; + +@Singleton +public class FeaturedServerTracker implements PluginFacet, ModelListener { + + private final Configuration config; + private final Server localServer; + private final ServerStore servers; + + private final Map featuredServersByFamily = new HashMap<>(); + + // Order servers by player count, as long as they aren't full + private final Comparator featuredServerOrder = Comparator + .nullsLast(null) + .thenComparing(server -> !server.online()) // Avoid offline servers + .thenComparing(this::isAlmostEmpty) // Avoid empty servers + .thenComparing(this::isAlmostFull) // Avoid full servers + .thenComparing(comparing(Server::num_online).reversed()); // Choose the fullest server + + @Inject FeaturedServerTracker(Configuration config, Server localServer, ServerStore servers, ModelDispatcher modelDispatcher) { + this.config = config; + this.localServer = localServer; + this.servers = servers; + modelDispatcher.subscribe(this); + } + + @Override + public void enable() { + servers.all().forEach(this::refreshServer); + } + + public String localDatacenter() { + return config.getString("local-datacenter-override", localServer.datacenter()); + } + + public boolean isMappable(Server server) { + return server != null && + server.alive() && + server.running() && + server.bungee_name() != null && + server.visibility() == ServerDoc.Visibility.PUBLIC && + server.datacenter().equals(localDatacenter()); + } + + public @Nullable Server featuredServerForFamily(String family) { + return featuredServersByFamily.get(family); + } + + /** + * Server is "almost full" if free space is less than 10% or 3 slots, whichever is greater + */ + public boolean isAlmostFull(Server server) { + return server.num_participating() > Math.min(server.max_players() * 0.9, server.max_players() - 3); + } + + public boolean isAlmostEmpty(Server server) { + return server.num_online() <= 1; + } + + private void refreshServer(Server changed) { + Server featured = featuredServersByFamily.get(changed.family()); + if(changed.equals(featured)) { + // If featured server changed, check entire family for a better server + refreshFamily(changed.family()); + } else if(isMappable(changed) && featuredServerOrder.compare(changed, featured) < 0) { + // Otherwise, just check if the changed server should replace the current featured one + featuredServersByFamily.put(changed.family(), changed); + } + } + + private void refreshFamily(final String family) { + final Server server = servers.first(featuredServerOrder, s -> isMappable(s) && family.equals(s.family())); + if(server != null) { + featuredServersByFamily.put(family, server); + } else { + featuredServersByFamily.remove(family); + } + } + + @HandleModel + public void serverUpdated(@Nullable Server before, @Nullable Server after, Server latest) { + refreshServer(latest); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/teleport/Navigator.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/teleport/Navigator.java new file mode 100644 index 0000000..e9aaf2f --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/teleport/Navigator.java @@ -0,0 +1,398 @@ +package tc.oc.commons.bukkit.teleport; + +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import javax.annotation.Nullable; +import javax.inject.Inject; + +import com.google.common.cache.LoadingCache; +import com.google.common.collect.ImmutableList; +import com.google.inject.Singleton; +import net.md_5.bungee.api.chat.BaseComponent; +import net.md_5.bungee.api.chat.TranslatableComponent; +import org.bukkit.entity.Player; +import tc.oc.api.docs.Arena; +import tc.oc.api.docs.Game; +import tc.oc.api.docs.Server; +import tc.oc.api.docs.virtual.ServerDoc; +import tc.oc.api.games.ArenaStore; +import tc.oc.api.games.GameStore; +import tc.oc.api.model.ModelDispatcher; +import tc.oc.api.model.ModelListener; +import tc.oc.api.servers.ServerStore; +import tc.oc.commons.bukkit.format.GameFormatter; +import tc.oc.commons.bukkit.ticket.TicketBooth; +import tc.oc.commons.core.plugin.PluginFacet; +import tc.oc.commons.core.util.CacheUtils; +import tc.oc.commons.core.util.Utils; + +@Singleton +public class Navigator implements PluginFacet, ModelListener { + + private static final char SERVER_SIGIL = '@'; + private static final char FAMILY_SIGIL = '.'; + private static final char GAME_SIGIL = '!'; + private static final char SPECIAL_SIGIL = '$'; + + private final GameStore games; + private final ArenaStore arenas; + private final ServerStore servers; + private final Teleporter teleporter; + private final TicketBooth ticketBooth; + private final FeaturedServerTracker featuredServerTracker; + + private final EmptyConnector emptyConnector = new EmptyConnector(); + private final DefaultConnector defaultConnector = new DefaultConnector(); + private final LoadingCache serverConnectors = CacheUtils.newCache(SingleServerConnector::new); + private final LoadingCache familyConnectors = CacheUtils.newCache(FeaturedServerConnector::new); + private final LoadingCache gameConnectors = CacheUtils.newCache(GameConnector::new); + + @Inject Navigator(GameStore games, ArenaStore arenas, ServerStore servers, Teleporter teleporter, TicketBooth ticketBooth, ModelDispatcher modelDispatcher, FeaturedServerTracker featuredServerTracker) { + this.games = games; + this.arenas = arenas; + this.servers = servers; + this.teleporter = teleporter; + this.ticketBooth = ticketBooth; + this.featuredServerTracker = featuredServerTracker; + modelDispatcher.subscribe(this); + } + + private String localDatacenter() { + return featuredServerTracker.localDatacenter(); + } + + public @Nullable Connector parseConnector(Collection tokens) { + final List connectors = tokens.stream() + .map(this::parseConnector) + .filter(c -> c != null) + .collect(Collectors.toList()); + return connectors.isEmpty() ? null : new MultiConnector(connectors); + } + + public @Nullable Connector parseConnector(String token) { + if(token.length() == 0) return null; + + final String name = token.substring(1); + switch(token.charAt(0)) { + case SERVER_SIGIL: return serverConnectors.getUnchecked(name); + case FAMILY_SIGIL: return familyConnectors.getUnchecked(name); + case GAME_SIGIL: return gameConnectors.getUnchecked(name); + case SPECIAL_SIGIL: + switch(name) { + case "default": return defaultConnector; + } + break; + } + + return null; + } + + public Connector combineConnectors(List connectors) { + switch(connectors.size()) { + case 0: return emptyConnector; + case 1: return connectors.get(0); + default: return new MultiConnector(connectors); + } + } + + @HandleModel + public void serverUpdated(@Nullable Server before, @Nullable Server after, Server latest) { + if(latest.bungee_name() != null) { + final SingleServerConnector serverConnector = serverConnectors.getIfPresent(latest.bungee_name()); + if(serverConnector != null) serverConnector.refresh(); + } + + if(latest.family() != null) { + final FeaturedServerConnector featuredServerConnector = familyConnectors.getIfPresent(latest.family()); + if(featuredServerConnector != null) featuredServerConnector.refresh(); + } + } + + @HandleModel + public void arenaUpdated(@Nullable Arena before, @Nullable Arena after, Arena latest) { + final GameConnector gameConnector = gameConnectors.getIfPresent(latest.game_id()); + if(gameConnector != null) gameConnector.refresh(); + } + + @HandleModel + public void gameUpdated(@Nullable Game before, @Nullable Game after, Game latest) { + final GameConnector gameConnector = gameConnectors.getIfPresent(latest._id()); + if(gameConnector != null) gameConnector.refresh(); + } + + public static final Object DEFAULT_MAPPING = new Object(); + + public abstract class Connector { + public void startObserving(Consumer observer) {} + public void stopObserving(Consumer observer) {} + public void release() {} + + public abstract @Nullable Object mappedTo(); + public boolean isVisible() { return true; } + public boolean isConnectable() { return true; } + public int priority() { return 0; } + public @Nullable BaseComponent description() { return null; } + + public abstract void teleport(Player player); + } + + public class EmptyConnector extends Connector { + @Override + public Object mappedTo() { return null; } + + @Override + public void teleport(Player player) {} + } + + public class DefaultConnector extends Connector { + @Override + public String toString() { + return getClass().getSimpleName() + "{}"; + } + + @Override + public Object mappedTo() { + return DEFAULT_MAPPING; + } + + @Override + public void teleport(Player player) { + if(ticketBooth.currentGame(player) != null) { + ticketBooth.leaveGame(player, true); + } else { + teleporter.sendToLobby(player, false); + } + } + } + + public abstract class DynamicConnector extends Connector { + private final Set> observers = new HashSet<>(); + + @Override + public void startObserving(Consumer observer) { + observers.add(observer); + } + + @Override + public void stopObserving(Consumer observer) { + observers.remove(observer); + } + + protected void notifyObservers() { + observers.forEach(o -> o.accept(this)); + } + } + + public abstract class ServerConnector extends DynamicConnector { + @Nullable protected Server server; + + @Override + public void teleport(Player player) { + teleporter.remoteTeleport(player, server); + } + + @Override + public Object mappedTo() { + return server; + } + + @Override + public BaseComponent description() { + return server != null && server.description() != null ? new TranslatableComponent(server.description()) + : super.description(); + } + + @Override + public boolean isVisible() { + return server != null && + server.datacenter().equals(localDatacenter()) && + server.visibility() == ServerDoc.Visibility.PUBLIC && + server.running(); + } + + @Override + public boolean isConnectable() { + return server != null && + server.datacenter().equals(localDatacenter()) && + server.visibility() != ServerDoc.Visibility.PRIVATE && + server.online(); + } + } + + public class SingleServerConnector extends ServerConnector { + private final String bungeeName; + + public SingleServerConnector(String bungeeName) { + this.bungeeName = bungeeName; + refresh(); + } + + @Override + public String toString() { + return getClass().getSimpleName() + "{server=" + bungeeName + "}"; + } + + protected void refresh() { + server = servers.tryBungeeName(bungeeName); + notifyObservers(); + } + } + + public class FeaturedServerConnector extends ServerConnector { + private final String familyId; + + public FeaturedServerConnector(String familyId) { + this.familyId = familyId; + refresh(); + } + + @Override + public String toString() { + return getClass().getSimpleName() + "{family=" + familyId + "}"; + } + + protected void refresh() { + server = featuredServerTracker.featuredServerForFamily(familyId); + notifyObservers(); + } + } + + public class GameConnector extends DynamicConnector { + private final String gameId; + private @Nullable Game game; + private @Nullable Arena arena; + + public GameConnector(String gameId) { + this.gameId = gameId; + refresh(); + } + + @Override + public String toString() { + return getClass().getSimpleName() + "{game=" + gameId + "}"; + } + + @Override + public Object mappedTo() { + return arena; + } + + @Override + public BaseComponent description() { + return game != null ? new TranslatableComponent(GameFormatter.descriptionKey(game)) + : super.description(); + } + + @Override + public boolean isVisible() { + return arena != null && + game.visibility() == ServerDoc.Visibility.PUBLIC; + } + + @Override + public boolean isConnectable() { + return arena != null && + game.visibility() != ServerDoc.Visibility.PRIVATE; + } + + public void refresh() { + arena = arenas.tryDatacenterAndGameId(localDatacenter(), gameId); + game = arena == null ? null : games.byId(arena.game_id()); + notifyObservers(); + } + + @Override + public void teleport(Player player) { + ticketBooth.playGame(player, arena); + } + } + + private class MultiConnector extends DynamicConnector { + + private final ImmutableList connectors; + private final Consumer observer = this::refresh; + private @Nullable Connector mapped; + + private MultiConnector(List connectors) { + this.connectors = ImmutableList.copyOf(connectors); + for(Connector connector : this.connectors) { + connector.startObserving(observer); + } + refresh(null); + } + + @Override + public String toString() { + return getClass().getSimpleName() + + "{connectors=[" + + connectors.stream() + .map(Object::toString) + .collect(Collectors.joining(", ")) + + "]}"; + } + + @Override + public int hashCode() { + return Objects.hash(connectors); + } + + @Override + public boolean equals(Object obj) { + return Utils.equals(MultiConnector.class, this, obj, that -> this.connectors.equals(that.connectors)); + } + + @Override + public Object mappedTo() { + return mapped == null ? null : mapped.mappedTo(); + } + + @Override + public BaseComponent description() { + return mapped == null ? null : mapped.description(); + } + + @Override + public boolean isVisible() { + return mapped != null && mapped.isVisible(); + } + + @Override + public boolean isConnectable() { + return mapped != null && mapped.isConnectable(); + } + + @Override + public void release() { + connectors.forEach(c -> c.stopObserving(observer)); + } + + @Override + public void teleport(Player player) { + if(mapped != null && mapped.isConnectable()) { + mapped.teleport(player); + } + } + + private @Nullable Connector choose() { + return connectors.stream() + .filter(Connector::isVisible) + .findFirst() + .orElse(null); + } + + private void refresh(@Nullable Connector changed) { + final Connector mapped = choose(); + if(!Objects.equals(this.mapped, mapped)) { + this.mapped = mapped; + notifyObservers(); + } else if(changed != null && changed.equals(this.mapped)) { + notifyObservers(); + } + } + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/teleport/NavigatorInterface.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/teleport/NavigatorInterface.java new file mode 100644 index 0000000..1be37d8 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/teleport/NavigatorInterface.java @@ -0,0 +1,371 @@ +package tc.oc.commons.bukkit.teleport; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import javax.annotation.Nullable; +import javax.inject.Inject; +import javax.inject.Singleton; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import net.md_5.bungee.api.ChatColor; +import net.md_5.bungee.api.chat.BaseComponent; +import net.md_5.bungee.api.chat.TranslatableComponent; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.enchantments.Enchantment; +import org.bukkit.entity.Player; +import org.bukkit.event.Event; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.inventory.ClickType; +import org.bukkit.event.inventory.InventoryType; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryView; +import org.bukkit.inventory.ItemFlag; +import org.bukkit.inventory.ItemStack; +import tc.oc.api.docs.Arena; +import tc.oc.api.docs.Game; +import tc.oc.api.docs.Server; +import tc.oc.api.docs.virtual.ServerDoc; +import tc.oc.api.games.GameStore; +import tc.oc.commons.bukkit.chat.ComponentRenderContext; +import tc.oc.commons.bukkit.config.ExternalConfiguration; +import tc.oc.commons.bukkit.configuration.ConfigUtils; +import tc.oc.commons.bukkit.event.ObserverKitApplyEvent; +import tc.oc.commons.bukkit.format.GameFormatter; +import tc.oc.commons.bukkit.format.ServerFormatter; +import tc.oc.commons.bukkit.inventory.Slot; +import tc.oc.commons.bukkit.item.ItemConfigurationParser; +import tc.oc.commons.bukkit.item.RenderedItemBuilder; +import tc.oc.commons.bukkit.listeners.ButtonListener; +import tc.oc.commons.bukkit.listeners.ButtonManager; +import tc.oc.commons.bukkit.listeners.WindowListener; +import tc.oc.commons.bukkit.listeners.WindowManager; +import tc.oc.commons.bukkit.ticket.TicketBooth; +import tc.oc.commons.core.chat.Component; +import tc.oc.commons.core.chat.Components; +import tc.oc.commons.core.inject.InnerFactory; +import tc.oc.commons.core.plugin.PluginFacet; +import tc.oc.minecraft.api.configuration.InvalidConfigurationException; + +import static tc.oc.commons.core.exception.LambdaExceptionUtils.rethrowFunction; + +@Singleton +public class NavigatorInterface implements PluginFacet, Listener { + + private final GameStore games; + private final ServerFormatter serverFormatter = ServerFormatter.light; + private final GameFormatter gameFormatter; + private final TicketBooth ticketBooth; + private final ButtonManager buttonManager; + private final WindowManager windowManager; + private final RenderedItemBuilder.Factory itemBuilders; + private final ComponentRenderContext renderer; + private final Server localServer; + private final Navigator navigator; + + private boolean enabled; + private int height; + private BaseComponent title = new TranslatableComponent("navigator.title"); + private ItemStack openButtonIcon = new ItemStack(Material.SIGN); + private Slot.Player openButtonSlot = Slot.Hotbar.forPosition(0); + + private ImmutableMap buttons = ImmutableMap.of(); + private final Set openWindows = new HashSet<>(); + + @Inject NavigatorInterface(GameStore games, + GameFormatter gameFormatter, + TicketBooth ticketBooth, + ButtonManager buttonManager, + RenderedItemBuilder.Factory itemBuilders, + WindowManager windowManager, + ComponentRenderContext renderer, + Server localServer, + Navigator navigator, + InnerFactory configFactory) { + this.games = games; + this.gameFormatter = gameFormatter; + this.ticketBooth = ticketBooth; + this.buttonManager = buttonManager; + this.itemBuilders = itemBuilders; + this.windowManager = windowManager; + this.renderer = renderer; + this.localServer = localServer; + this.navigator = navigator; + + configFactory.create(this); + } + + public void setOpenButtonSlot(Slot.Player openButtonSlot) { + this.openButtonSlot = openButtonSlot; + } + + private final ButtonListener openButtonListener = (button, clicker, clickType, event) -> { + if(clickType == ClickType.RIGHT) { + openWindow(clicker); + return true; + } + return false; + }; + + private ItemStack createOpenButton(Player player) { + return buttonManager.createButton(openButtonListener, + itemBuilders.create(player, openButtonIcon) + .flags(ItemFlag.values()) + .name(new Component(title, ChatColor.AQUA, ChatColor.BOLD)) + .get()); + } + + public void giveOpenButton(Player player) { + openButtonSlot.putItem(player, createOpenButton(player)); + } + + @EventHandler + public void onObserve(ObserverKitApplyEvent event) { + if(enabled) { + giveOpenButton(event.getPlayer()); + } + } + + private final WindowListener windowListener = new WindowListener() { + @Override public void windowOpened(InventoryView window) { + openWindows.add(window); + } + + @Override public void windowClosed(InventoryView window) { + openWindows.remove(window); + } + + @Override + public boolean windowClicked(InventoryView window, Inventory inventory, ClickType clickType, InventoryType.SlotType slotType, int slotIndex, @Nullable ItemStack item) { + return true; + } + }; + + private Inventory createWindow(Player player) { + final Inventory inventory = Bukkit.createInventory( + player, + height * 9, + renderer.renderLegacy(new Component(title, ChatColor.DARK_AQUA, ChatColor.BOLD), player) + ); + buttons.values().forEach(handler -> handler.updateWindow(player, inventory)); + return inventory; + } + + private void openWindow(Player player) { + if(enabled && !buttons.isEmpty()) { + windowManager.openWindow(windowListener, player, createWindow(player)); + } + } + + private void closeAllWindows() { + ImmutableList.copyOf(openWindows).forEach(InventoryView::close); + openWindows.clear(); + } + + private void clear() { + closeAllWindows(); + NavigatorInterface.this.enabled = false; + NavigatorInterface.this.buttons.values().forEach(Button::release); + NavigatorInterface.this.buttons = ImmutableMap.of(); + } + + class Configuration extends ExternalConfiguration { + + @Inject public Configuration() {} + + @Override + protected String configName() { + return "navigator"; + } + + @Override + protected String fileName() { + return "navigator-" + localServer.datacenter(); + } + + @Override + protected void configChanged(@Nullable ConfigurationSection before, @Nullable ConfigurationSection after) throws InvalidConfigurationException { + super.configChanged(before, after); + if(after != null) { + load(after); + } else { + clear(); + } + } + + void load(ConfigurationSection config) throws InvalidConfigurationException { + final boolean enabled; + final BaseComponent title; + final ItemStack openButtonIcon; + final Map buttons = new HashMap<>(); + + try { + enabled = config.getBoolean("enabled", false); + title = new TranslatableComponent(config.getString("title", "navigator.title")); + final ItemConfigurationParser itemParser = new ItemConfigurationParser(config); + openButtonIcon = itemParser.getItem(config, "icon", () -> new ItemStack(Material.SIGN)); + + if(enabled) { + final ConfigurationSection buttonSection = config.getSection("buttons"); + for(String key : buttonSection.getKeys()) { + final Button button = new Button(buttonSection.getSection(key), itemParser); + buttons.put(button.slot, button); + } + } + } catch(InvalidConfigurationException e) { + buttons.values().forEach(Button::release); + throw e; + } + + clear(); + + NavigatorInterface.this.enabled = enabled; + NavigatorInterface.this.title = title; + NavigatorInterface.this.openButtonIcon = openButtonIcon; + NavigatorInterface.this.buttons = ImmutableMap.copyOf(buttons); + NavigatorInterface.this.height = buttons.values() + .stream() + .mapToInt(button -> button.slot.getRow() + 1) + .max() + .orElse(0); + } + } + + private class Button implements ButtonListener { + + final Slot.Container slot; + final ItemStack icon; + final Navigator.Connector connector; + + final Consumer observer = c -> + openWindows.forEach(window -> updateWindow((Player) window.getPlayer(), window.getTopInventory())); + + Button(ConfigurationSection config, ItemConfigurationParser itemParser) throws InvalidConfigurationException { + this.slot = itemParser.needSlotByPosition(config, null, null, Slot.Container.class); + this.icon = config.isString("skull") ? itemParser.needSkull(config, "skull") + : itemParser.needItem(config, "icon"); + this.connector = navigator.combineConnectors(ConfigUtils.needValueOrList(config, "to", String.class).stream() + .map(rethrowFunction(token -> parseConnector(config, "to", token))) + .collect(Collectors.toList())); + this.connector.startObserving(observer); + } + + private Navigator.Connector parseConnector(ConfigurationSection section, String key, String token) throws InvalidConfigurationException { + final Navigator.Connector connector = navigator.parseConnector(token); + if(connector == null) { + throw new InvalidConfigurationException(section, key, "Invalid connector token '" + token + "'"); + } + return connector; + } + + void release() { + buttonManager.unregisterListener(this); + connector.stopObserving(observer); + connector.release(); + } + + @Override + public boolean buttonClicked(ItemStack stack, Player clicker, ClickType clickType, Event event) { + if(connector.isConnectable()) { + windowManager.closeWindow(clicker); + connector.teleport(clicker); + } + return true; + } + + void updateWindow(Player viewer, Inventory inventory) { + final ItemStack stack = createButton(viewer); + if(!Objects.equals(stack, slot.getItem(inventory))) { + slot.putItem(inventory, stack); + } + } + + @Nullable ItemStack createButton(Player viewer) { + final ItemStack icon = createIcon(viewer); + return icon == null ? null : buttonManager.createButton(this, icon); + } + + @Nullable ItemStack createIcon(Player viewer) { + if(!connector.isVisible()) return null; + + final RenderedItemBuilder icon = itemBuilders.create(viewer, this.icon.clone()) + .flags(ItemFlag.values()); + final Object mapped = connector.mappedTo(); + if(Navigator.DEFAULT_MAPPING.equals(mapped)) { + renderDefault(viewer, icon); + } else if(mapped instanceof Server) { + renderServer(viewer, icon, (Server) mapped); + } else if(mapped instanceof Arena) { + renderArena(viewer, icon, (Arena) mapped); + } + return icon.get(); + } + + void renderDefault(Player viewer, RenderedItemBuilder icon) { + final Game game = ticketBooth.currentGame(viewer); + if(game != null) { + icon.name(gameFormatter.leave(game)); + } else { + icon.name(new Component(new TranslatableComponent("servers.backToLobby"), ChatColor.AQUA)); + } + } + + void renderServer(Player viewer, RenderedItemBuilder icon, Server server) { + icon.name(serverFormatter.name(server)); + serverFormatter.description(server).ifPresent(icon::lore); + icon.lore(Components.blank()); + + if(serverFormatter.isRestarting(server)) { + icon.lore(serverFormatter.onlineStatus(server)); + } else { + icon.amount(Math.max(1, server.num_online())); + + if(server.role() == ServerDoc.Role.LOBBY) { + icon.lore(gameFormatter.onlineCount(server)); + } else { + icon.lore(gameFormatter.playingCount(server)); + icon.lore(gameFormatter.watchingCount(server)); + icon.lore(Components.blank()); + + if(server.current_match() != null) { + if(server.current_match().map() != null && server.current_match().end() == null) { + // Show current map if match is not finished + icon.lore(serverFormatter.currentMap(server)); + } + + if(server.num_online() > 0 && + server.restart_queued_at() == null && + (server.current_match().start() == null || server.current_match().end() != null)) { + // Enchant if server has players, and is not restarting, and is between matches + icon.enchant(Enchantment.ARROW_INFINITE, 1); + } + + } + + if(server.next_map() != null) { + icon.lore(serverFormatter.nextMap(server)); + } + } + } + } + + void renderArena(Player viewer, RenderedItemBuilder icon, Arena arena) { + final Game game = games.byId(arena.game_id()); + icon.amount(Math.max(1, arena.num_playing() + arena.num_queued())) + .name(new Component(game.name(), ChatColor.AQUA, ChatColor.BOLD)) + .lore(gameFormatter.description(game)) + .lore(Components.blank()) + .lore(gameFormatter.playingCount(arena)) + .lore(gameFormatter.waitingCount(arena)) + .get(); + } + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/teleport/NavigatorManifest.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/teleport/NavigatorManifest.java new file mode 100644 index 0000000..1fd772c --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/teleport/NavigatorManifest.java @@ -0,0 +1,19 @@ +package tc.oc.commons.bukkit.teleport; + +import tc.oc.commons.core.inject.HybridManifest; +import tc.oc.commons.core.inject.InnerFactoryManifest; +import tc.oc.commons.core.plugin.PluginFacetBinder; + +public class NavigatorManifest extends HybridManifest { + @Override + protected void configure() { + install(InnerFactoryManifest.forInnerClass(NavigatorInterface.Configuration.class)); + + expose(Navigator.class); + expose(NavigatorInterface.class); // Only exposed so that other plugins can call setOpenButtonSlot + + final PluginFacetBinder facets = new PluginFacetBinder(binder()); + facets.register(Navigator.class); + facets.register(NavigatorInterface.class); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/teleport/PlayerServerChanger.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/teleport/PlayerServerChanger.java new file mode 100644 index 0000000..acaef29 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/teleport/PlayerServerChanger.java @@ -0,0 +1,101 @@ +package tc.oc.commons.bukkit.teleport; + +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import javax.annotation.Nullable; +import javax.inject.Inject; + +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.SettableFuture; +import net.md_5.bungee.api.ChatColor; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.metadata.FixedMetadataValue; +import org.bukkit.metadata.MetadataValue; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.messaging.Messenger; +import tc.oc.api.docs.Server; +import tc.oc.api.docs.virtual.ServerDoc; +import tc.oc.commons.core.plugin.PluginFacet; + +public class PlayerServerChanger implements PluginFacet, Listener { + + private static final String PLUGIN_CHANNEL = "BungeeCord"; + private static final String METADATA_KEY = "QuitFuture"; + + private final Plugin plugin; + private final Messenger messenger; + private final Server localServer; + + @Inject PlayerServerChanger(Plugin plugin, Messenger messenger, Server localServer) { + this.plugin = plugin; + this.messenger = messenger; + this.localServer = localServer; + } + + @Override + public void enable() { + messenger.registerOutgoingPluginChannel(plugin, PLUGIN_CHANNEL); + } + + @Override + public void disable() { + messenger.unregisterOutgoingPluginChannel(plugin, PLUGIN_CHANNEL); + } + + private ListenableFuture quitFuture(Player player) { + if(player.isOnline()) { + final SettableFuture future = SettableFuture.create(); + player.setMetadata(METADATA_KEY, new FixedMetadataValue(plugin, future)); + return future; + } else { + return Futures.immediateFuture(null); + } + } + + @EventHandler(priority = EventPriority.LOWEST) + public void onQuit(PlayerQuitEvent event) { + final MetadataValue future = event.getPlayer().getMetadata(METADATA_KEY, plugin); + if(future != null) { + ((SettableFuture) future.value()).set(null); + } + } + + public ListenableFuture sendPlayerToLobby(Player player, boolean quiet) { + return sendPlayerToServer(player, (String) null, quiet); + } + + public ListenableFuture sendPlayerToServer(Player player, @Nullable ServerDoc.BungeeName server, boolean quiet) { + return sendPlayerToServer(player, server == null ? null : server.bungee_name(), quiet); + } + + public ListenableFuture sendPlayerToServer(Player player, @Nullable String bungeeName, boolean quiet) { + if(localServer.bungee_name().equals(bungeeName) || (localServer.role() == ServerDoc.Role.LOBBY && bungeeName == null)) { + return Futures.immediateFuture(null); + } + + final ByteArrayOutputStream message = new ByteArrayOutputStream(); + final DataOutputStream out = new DataOutputStream(message); + + try { + out.writeUTF(quiet ? "ConnectQuiet" : "Connect"); + out.writeUTF(bungeeName == null ? "default" : bungeeName); + } catch(IOException e) { + return Futures.immediateFailedFuture(e); + } + + player.sendPluginMessage(plugin, PLUGIN_CHANNEL, message.toByteArray()); + return quitFuture(player); + } + + public ListenableFuture kickPlayer(Player player, String message) { + // Magic color sequence signals Bungee to disconnect the player + player.kickPlayer(ChatColor.BLACK.toString() + ChatColor.RED + ChatColor.RESET + message); + return quitFuture(player); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/teleport/TeleportCommands.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/teleport/TeleportCommands.java new file mode 100644 index 0000000..041de6f --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/teleport/TeleportCommands.java @@ -0,0 +1,73 @@ +package tc.oc.commons.bukkit.teleport; + +import javax.inject.Inject; + +import com.google.common.util.concurrent.ListenableFuture; +import com.sk89q.minecraft.util.commands.Command; +import com.sk89q.minecraft.util.commands.CommandContext; +import com.sk89q.minecraft.util.commands.CommandException; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import tc.oc.minecraft.scheduler.SyncExecutor; +import tc.oc.api.users.UserSearchResponse; +import tc.oc.commons.bukkit.chat.NameStyle; +import tc.oc.commons.bukkit.chat.PlayerComponent; +import tc.oc.commons.bukkit.commands.CommandUtils; +import tc.oc.commons.bukkit.commands.UserFinder; +import tc.oc.commons.bukkit.nick.IdentityProvider; +import tc.oc.commons.core.commands.CommandFutureCallback; +import tc.oc.commons.core.commands.Commands; +import tc.oc.commons.core.commands.TranslatableCommandException; + +public class TeleportCommands implements Commands { + + private final SyncExecutor syncExecutor; + private final Teleporter teleporter; + private final UserFinder userFinder; + private final IdentityProvider identityProvider; + + @Inject TeleportCommands(SyncExecutor syncExecutor, Teleporter teleporter, UserFinder userFinder, IdentityProvider identityProvider) { + this.syncExecutor = syncExecutor; + this.teleporter = teleporter; + this.userFinder = userFinder; + this.identityProvider = identityProvider; + } + + @Command( + aliases = { "remoteteleport", "rtp", "goto" }, + desc = "Teleport to a player anywhere on the network", + usage = "[traveler] ", + min = 1, + max = 2 + ) + public void remoteTeleport(final CommandContext args, final CommandSender sender) throws CommandException { + final Player traveler; + final ListenableFuture future; + + if(args.argsLength() >= 2) { + CommandUtils.assertPermission(sender, Teleporter.PERMISSION_OTHERS); + traveler = CommandUtils.findOnlinePlayer(args, sender, 0); + future = userFinder.findUser(sender, args, 1); + } else { + CommandUtils.assertPermission(sender, Teleporter.PERMISSION); + traveler = CommandUtils.senderToPlayer(sender); + future = userFinder.findUser(sender, args, 0); + } + + syncExecutor.callback( + future, + CommandFutureCallback.onSuccess(sender, args, result -> { + final PlayerComponent playerComponent = new PlayerComponent(identityProvider.createIdentity(result), NameStyle.FANCY); + + if(!result.online) { + throw new TranslatableCommandException("command.playerNotOnline", playerComponent); + } else if(result.last_server == null) { + // Probably because player has disabled "show current server" + throw new TranslatableCommandException("command.playerLocationUnavailable", playerComponent); + } else { + teleporter.remoteTeleport(traveler, result.last_server, result.user.uuid()); + } + }) + ); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/teleport/TeleportListener.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/teleport/TeleportListener.java new file mode 100644 index 0000000..e2af2e0 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/teleport/TeleportListener.java @@ -0,0 +1,147 @@ +package tc.oc.commons.bukkit.teleport; + +import java.time.Duration; +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.logging.Logger; +import javax.inject.Inject; +import javax.inject.Singleton; + +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; +import tc.oc.api.bukkit.users.OnlinePlayers; +import tc.oc.api.message.MessageListener; +import tc.oc.api.message.MessageQueue; +import tc.oc.api.message.types.PlayerTeleportRequest; +import tc.oc.commons.bukkit.permissions.PermissionRegistry; +import tc.oc.commons.core.logging.Loggers; +import tc.oc.commons.core.plugin.PluginFacet; +import tc.oc.minecraft.api.scheduler.Scheduler; +import tc.oc.minecraft.api.scheduler.Tickable; +import tc.oc.minecraft.scheduler.SyncExecutor; + +/** + * Listens for remote teleport requests and executes both ends of them. + * + * When the request target is a different server, this class sends the + * player there (via Bungee plugin channel). We do this in Bukkit so that + * it can be cancelled i.e. by PGM when the player is in a match. + * + * When the target is this server, and there is also a target player, + * this class waits for the teleporting player to arrive and then + * teleports them to the target. + */ +@Singleton +public class TeleportListener implements MessageListener, Listener, PluginFacet, Tickable { + + private static final Duration TIMEOUT = Duration.ofSeconds(10); + + static class Received { + final java.time.Instant timestamp = java.time.Instant.now(); + final PlayerTeleportRequest request; + + Received(PlayerTeleportRequest request) { + this.request = request; + } + } + + private final Logger logger; + private final MessageQueue primaryQueue; + private final Teleporter teleporter; + private final SyncExecutor syncExecutor; + private final Scheduler scheduler; + private final PermissionRegistry permissionRegistry; + private final OnlinePlayers onlinePlayers; + + private final Map requests = new HashMap<>(); + + @Inject TeleportListener(Loggers loggers, MessageQueue primaryQueue, Teleporter teleporter, SyncExecutor syncExecutor, Scheduler scheduler, PermissionRegistry permissionRegistry, OnlinePlayers onlinePlayers) { + this.logger = loggers.get(getClass()); + this.primaryQueue = primaryQueue; + this.onlinePlayers = onlinePlayers; + this.syncExecutor = syncExecutor; + this.scheduler = scheduler; + this.permissionRegistry = permissionRegistry; + this.teleporter = teleporter; + } + + @Override + public Duration tickPeriod() { + return TIMEOUT; + } + + @Override + public void enable() { + permissionRegistry.addPermission(Teleporter.PERMISSION); + primaryQueue.subscribe(this, syncExecutor); + primaryQueue.bind(PlayerTeleportRequest.class); + } + + @Override + public void disable() { + primaryQueue.unsubscribe(this); + } + + @Override + public void tick() { + final Instant now = Instant.now(); + requests.values().removeIf(received -> received.timestamp.plus(TIMEOUT).isBefore(now)); + } + + @HandleMessage + public void onTeleport(PlayerTeleportRequest request) { + Player traveler = onlinePlayers.find(request.player_uuid); + + if(!teleporter.isLocal(request.target_server())) { + // Send player to another server + if(traveler != null && traveler.hasPermission(Teleporter.PERMISSION)) { + if(request.target_server() == null) { + logger.info("Sending " + traveler.getName() + " to lobby"); + } else { + logger.info("Sending " + traveler.getName() + " to server " + request.target_server().bungee_name()); + } + teleporter.remoteTeleport(traveler, request.target_server()); + } + } else if(request.target_player_uuid != null) { + // Teleport player to a target on this server + if(traveler != null) { + doTeleport(traveler, request); + } else { + queueTeleport(request); + } + } + } + + /** + * Priority must be high enough so that whatever applies observer permissions + * runs before this, since those include the teleport permission. Lobby does it + * in PlayerListener and PGM does it in SpawnMatchModule. + */ + @EventHandler(priority = EventPriority.MONITOR) + public void onJoin(PlayerJoinEvent event) { + Received received = requests.remove(event.getPlayer().getUniqueId()); + if(received != null) { + doTeleport(event.getPlayer(), received.request); + } + } + + private void queueTeleport(final PlayerTeleportRequest request) { + logger.info("Queueing remote teleport for offline player " + request.player_uuid); + requests.put(request.player_uuid, new Received(request)); + + scheduler.delaySync(TIMEOUT, () -> { + if(requests.remove(request.player_uuid) != null) { + logger.warning("Expired remote teleport for " + request.player_uuid); + } + }); + } + + private void doTeleport(Player traveler, PlayerTeleportRequest request) { + teleporter.localTeleport(traveler, request.target_player_uuid); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/teleport/Teleporter.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/teleport/Teleporter.java new file mode 100644 index 0000000..59ff40e --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/teleport/Teleporter.java @@ -0,0 +1,166 @@ +package tc.oc.commons.bukkit.teleport; + +import java.util.UUID; +import javax.annotation.Nullable; +import javax.inject.Inject; +import javax.inject.Singleton; + +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import net.md_5.bungee.api.ChatColor; +import net.md_5.bungee.api.chat.BaseComponent; +import net.md_5.bungee.api.chat.TranslatableComponent; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.bukkit.permissions.Permission; +import org.bukkit.permissions.PermissionDefault; +import tc.oc.api.docs.Server; +import tc.oc.api.docs.virtual.ServerDoc; +import tc.oc.api.users.UserService; +import tc.oc.commons.bukkit.chat.Audiences; +import tc.oc.commons.bukkit.chat.NameStyle; +import tc.oc.commons.bukkit.chat.PlayerComponent; +import tc.oc.commons.bukkit.event.PlayerServerChangeEvent; +import tc.oc.commons.bukkit.format.ServerFormatter; +import tc.oc.commons.bukkit.nick.IdentityProvider; +import tc.oc.commons.core.chat.Audience; +import tc.oc.commons.core.chat.Component; +import tc.oc.commons.core.chat.NullAudience; + +@Singleton +public class Teleporter { + + public static final Permission PERMISSION = new Permission("ocn.teleport", PermissionDefault.FALSE); + public static final Permission PERMISSION_OTHERS = new Permission("ocn.teleport.others", PermissionDefault.OP); + public static final String CROSS_DATACENTER_PERMISSION = "server.cross-datacenter"; + + private final UserService userService; + private final IdentityProvider identityProvider; + private final Audiences audiences; + private final PlayerServerChanger playerServerChanger; + private final Server localServer; + + @Inject Teleporter(UserService userService, IdentityProvider identityProvider, Audiences audiences, PlayerServerChanger playerServerChanger, Server localServer) { + this.userService = userService; + this.identityProvider = identityProvider; + this.audiences = audiences; + this.playerServerChanger = playerServerChanger; + this.localServer = localServer; + } + + public boolean isConnectable(Server server) { + return server.online() && + server.visibility() != ServerDoc.Visibility.PRIVATE && + server.role() != ServerDoc.Role.BUNGEE && + server.network().equals(localServer.network()) && + server.datacenter().equals(localServer.datacenter()); + } + + public boolean isVisible(Server server) { + return isConnectable(server) && + server.visibility() == ServerDoc.Visibility.PUBLIC; + } + + public boolean isLocal(@Nullable ServerDoc.Identity destinationServer) { + return destinationServer == null ? localServer.role() == ServerDoc.Role.LOBBY + : localServer.bungee_name().equals(destinationServer.bungee_name()); + } + + public void showCurrentServer(CommandSender viewer) { + showCurrentServer(audiences.get(viewer)); + } + + public void showCurrentServer(Audience audience) { + audience.sendMessage(new Component( + new TranslatableComponent( + "command.server.currentServer", + ServerFormatter.light.nameWithDatacenter(localServer) + ), + ChatColor.DARK_PURPLE + )); + audience.sendMessage(new Component(new TranslatableComponent("command.server.switchPrompt"), ChatColor.GREEN)); + } + + public void localTeleport(Player traveler, @Nullable Player destinationPlayer) { + if(destinationPlayer != null && traveler.hasPermission(PERMISSION)) { + audiences.get(traveler).sendMessage(new Component( + new TranslatableComponent("command.server.teleporting", + new PlayerComponent(identityProvider.currentIdentity(destinationPlayer), NameStyle.VERBOSE)), + ChatColor.DARK_PURPLE + )); + traveler.teleport(destinationPlayer); + } + } + + public void localTeleport(Player traveler, @Nullable UUID destinationPlayer) { + if(destinationPlayer != null && traveler.hasPermission(PERMISSION)) { + localTeleport(traveler, traveler.getServer().getPlayer(destinationPlayer)); + } + } + + public void remoteTeleport(Player traveler, ServerDoc.Identity destinationServer, @Nullable UUID destinationPlayer) { + if(traveler.hasPermission(PERMISSION)) { + Player target = destinationPlayer == null ? null : traveler.getServer().getPlayer(destinationPlayer); + if(target != null) { + localTeleport(traveler, target); + } else if (!isLocal(destinationServer)) { + // TeleportListener will receive this message and call sendToServer + userService.requestTeleport(traveler.getUniqueId(), destinationServer, destinationPlayer); + } + } + } + + public ListenableFuture remoteTeleport(Player traveler, @Nullable ServerDoc.Identity server) { + return remoteTeleport(traveler, server, false); + } + + public ListenableFuture remoteTeleport(Player traveler, @Nullable ServerDoc.Identity server, boolean quiet) { + return server != null ? remoteTeleport(traveler, server.datacenter(), server.name(), server.bungee_name(), quiet) + : sendToLobby(traveler, quiet); + } + + public ListenableFuture remoteTeleport(Player traveler, @Nullable String datacenter, @Nullable String serverName, @Nullable String bungeeName) { + return remoteTeleport(traveler, datacenter, serverName, bungeeName, false); + } + + public ListenableFuture remoteTeleport(Player traveler, @Nullable String datacenter, @Nullable String serverName, @Nullable String bungeeName, boolean quiet) { + final Audience audience = quiet ? NullAudience.INSTANCE : audiences.get(traveler); + + if(datacenter == null || !traveler.hasPermission(CROSS_DATACENTER_PERMISSION)) { + datacenter = localServer.datacenter(); + } + + final BaseComponent fullName = ServerFormatter.light.nameWithDatacenter(datacenter, bungeeName, serverName, bungeeName == null); + + if((bungeeName == null && localServer.role() == ServerDoc.Role.LOBBY) || + (bungeeName != null && bungeeName.equals(localServer.bungee_name()))) { + showCurrentServer(audience); + return Futures.immediateFuture(null); + } + + PlayerServerChangeEvent event = new PlayerServerChangeEvent(traveler, datacenter, bungeeName, new TranslatableComponent("servers.cannotChange")); + traveler.getServer().getPluginManager().callEvent(event); + + if(event.isCancelled()) { + if(event.getCancelMessage() != null) { + audience.sendWarning(event.getCancelMessage(), false); + } + return Futures.immediateCancelledFuture(); + } + + audience.sendMessage(new Component(new TranslatableComponent("command.server.teleporting", fullName), ChatColor.DARK_PURPLE)); + return playerServerChanger.sendPlayerToServer(traveler, bungeeName, quiet); + } + + public ListenableFuture sendToLobby(Player player, @Nullable String datacenter, boolean quiet) { + return remoteTeleport(player, datacenter, null, null, quiet); + } + + public ListenableFuture sendToLobby(Player player, @Nullable String datacenter) { + return sendToLobby(player, datacenter, false); + } + + public ListenableFuture sendToLobby(Player player, boolean quiet) { + return sendToLobby(player, null, quiet); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/ticket/TicketBooth.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/ticket/TicketBooth.java new file mode 100644 index 0000000..b0572c3 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/ticket/TicketBooth.java @@ -0,0 +1,285 @@ +package tc.oc.commons.bukkit.ticket; + +import java.util.Comparator; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import javax.annotation.Nullable; +import javax.inject.Inject; +import javax.inject.Singleton; + +import com.google.common.collect.Sets; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import net.md_5.bungee.api.chat.TranslatableComponent; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import tc.oc.api.bukkit.users.BukkitUserStore; +import tc.oc.api.docs.Arena; +import tc.oc.api.docs.Game; +import tc.oc.api.docs.PlayerId; +import tc.oc.api.docs.Server; +import tc.oc.api.docs.Ticket; +import tc.oc.api.docs.virtual.MatchDoc; +import tc.oc.api.docs.virtual.ServerDoc; +import tc.oc.api.games.ArenaStore; +import tc.oc.api.games.GameStore; +import tc.oc.api.games.TicketService; +import tc.oc.api.games.TicketStore; +import tc.oc.api.message.types.PlayGameRequest; +import tc.oc.api.message.types.Reply; +import tc.oc.minecraft.scheduler.SyncExecutor; +import tc.oc.api.servers.ServerStore; +import tc.oc.commons.bukkit.chat.Audiences; +import tc.oc.commons.bukkit.chat.WarningComponent; +import tc.oc.commons.bukkit.format.GameFormatter; +import tc.oc.commons.bukkit.teleport.PlayerServerChanger; +import tc.oc.commons.bukkit.util.PlayerStates; +import tc.oc.commons.core.chat.Audience; +import tc.oc.commons.core.commands.CommandFutureCallback; + +/** + * User actions for querying, joining, and leaving {@link Game}s + */ +@Singleton +public class TicketBooth { + + private final SyncExecutor syncExecutor; + private final BukkitUserStore userStore; + private final PlayerStates playerStates; + private final Audiences audiences; + private final TicketService ticketService; + private final GameFormatter gameFormatter; + private final GameStore games; + private final ArenaStore arenas; + private final TicketStore tickets; + private final ServerStore servers; + private final PlayerServerChanger serverChanger; + private final Server localServer; + + private @Nullable PlayHandler playHandler; + + @Inject TicketBooth(SyncExecutor syncExecutor, + BukkitUserStore userStore, + PlayerStates playerStates, + Audiences audiences, + TicketService ticketService, + GameFormatter gameFormatter, + GameStore games, + ArenaStore arenas, + TicketStore tickets, + ServerStore servers, + PlayerServerChanger serverChanger, + Server localServer) { + + this.syncExecutor = syncExecutor; + this.userStore = userStore; + this.playerStates = playerStates; + this.audiences = audiences; + this.ticketService = ticketService; + this.gameFormatter = gameFormatter; + this.games = games; + this.arenas = arenas; + this.tickets = tickets; + this.servers = servers; + this.serverChanger = serverChanger; + this.localServer = localServer; + } + + @FunctionalInterface + public interface PlayHandler { + boolean requestPlay(Player player); + } + + public PlayHandler playHandler() { + return playHandler; + } + + public void setPlayHandler(PlayHandler handler) { + playHandler = handler; + } + + public void removePlayHandler(PlayHandler handler) { + if(handler.equals(playHandler)) { + playHandler = null; + } + } + + public Set allGames(CommandSender viewer) { + return Sets.filter(games.set(), game -> game.visibility() == ServerDoc.Visibility.PUBLIC); + } + + public void showGames(CommandSender sender) { + gameFormatter.sendList(audiences.get(sender), allGames(sender)); + } + + public @Nullable Arena localArena() { + final String id = localServer.arena_id(); + return id == null ? null : arenas.byId(id); + } + + public @Nullable Game localGame() { + final String id = localServer.game_id(); + return id == null ? null : games.byId(id); + } + + public @Nullable Arena currentArena(Player player) { + return currentArena(userStore.playerId(player)); + } + + public @Nullable Game currentGame(Player player) { + return currentGame(userStore.playerId(player)); + } + + public @Nullable Arena currentArena(PlayerId playerId) { + final Ticket ticket = tickets.tryUser(playerId); + return ticket == null ? null : arenas.byId(ticket.arena_id()); + } + + public @Nullable Game currentGame(PlayerId playerId) { + final Arena arena = currentArena(playerId); + return arena == null ? null : games.byId(arena.game_id()); + } + + public @Nullable Game findGame(CommandSender sender, String name) { + name = name.trim().toLowerCase(); + for(Game game : games.set()) { + if(game.visibility() != ServerDoc.Visibility.PRIVATE && + name.equals(game.name().toLowerCase())) { + return game; + } + } + audiences.get(sender).sendMessage(new WarningComponent("game.unknown", name)); + showGames(sender); + return null; + } + + public @Nullable Arena findArena(CommandSender sender, @Nullable String name) { + final Arena arena; + if(name == null || name.length() == 0) { + arena = localArena(); + if(arena == null) { + showGames(sender); + } + } else { + final Game game = findGame(sender, name); + if(game == null) return null; + + arena = arenas.tryDatacenterAndGameId(localServer.datacenter(), game._id()); + if(arena == null) { + audiences.get(sender).sendMessage(new WarningComponent("game.offline", gameFormatter.name(game))); + } + } + return arena; + } + + private ListenableFuture sendPlayRequest(Player player, @Nullable Arena arena) { + return sendPlayRequest(userStore.playerId(player), arena); + } + + private ListenableFuture sendPlayRequest(PlayerId playerId, @Nullable Arena arena) { + return sendPlayRequest(playerId, arena, false); + } + + private ListenableFuture sendPlayRequest(PlayerId playerId, @Nullable Arena arena, boolean force) { + final Arena playing = currentArena(playerId); + if(!force && Objects.equals(playing, arena)) { + return Futures.immediateFuture(Reply.SUCCESS); + } else { + return ticketService.requestPlay(new PlayGameRequest() { + @Override public String user_id() { return playerId._id(); } + @Override public @Nullable String arena_id() { return arena == null ? null : arena._id(); } + }); + } + } + + public void leaveGame(Player player, boolean returnToLobby) { + final Audience audience = audiences.get(player); + + final PlayerId playerId = userStore.playerId(player); + final Game game = currentGame(playerId); + if(game == null) { + audience.sendMessage(gameFormatter.notPlaying()); + return; + } + + syncExecutor.callback( + sendPlayRequest(playerId, null), + CommandFutureCallback.onSuccess(player, reply -> { + audience.sendMessage(gameFormatter.left(game)); + if(returnToLobby) { + serverChanger.sendPlayerToLobby(player, true); + } + }) + ); + } + + public void playLocalGame(Player player) { + final Game game = localGame(); + if(game != null) { + final Arena arena = arenas.tryDatacenterAndGameId(localServer.datacenter(), game._id()); + if(arena != null) { + playGame(player, arena); + } + } else if(playHandler != null) { + playHandler.requestPlay(player); + } else { + showGames(player); + } + } + + public void playGame(Player player, @Nullable String name) { + final Arena arena = findArena(player, name); + if(arena != null) { + playGame(player, arena); + } + } + + public void playGame(Player player, Arena arena) { + final PlayerId playerId = userStore.playerId(player); + boolean forceRequest = false; + if(arena.equals(currentArena(playerId))) { + final Game game = games.byId(arena.game_id()); + final Audience audience = audiences.get(player); + final MatchDoc match = localServer.current_match(); + if(match != null && match.join_mid_match()) { + audience.sendMessage(gameFormatter.alreadyPlaying(game)); + return; + } else { + audience.sendMessage(gameFormatter.replay(game)); + forceRequest = playerStates.isObserving(player); + } + } + sendPlayRequest(playerId, arena, forceRequest); + } + + public void watchGame(Player player, @Nullable String name) { + final Arena arena = findArena(player, name); + if(arena != null) { + watchGame(player, arena); + } + } + + public void watchGame(Player player, Arena arena) { + final Audience audience = audiences.get(player); + final Game game = games.byId(arena.game_id()); + final Optional fullest = servers.byArena(arena) + .stream() + .filter(Server::online) + .max(Comparator.comparing(Server::num_participating)); + if(!fullest.isPresent()) { + audience.sendWarning(new TranslatableComponent("game.offline", gameFormatter.name(game)), false); + return; + } else if(fullest.get().num_participating() < 2) { + audience.sendWarning(new TranslatableComponent("game.empty", gameFormatter.name(game)), false); + return; + } + + syncExecutor.callback( + sendPlayRequest(player, null), + reply -> { + serverChanger.sendPlayerToServer(player, fullest.get(), false); + } + ); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/ticket/TicketCommands.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/ticket/TicketCommands.java new file mode 100644 index 0000000..0c91dd1 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/ticket/TicketCommands.java @@ -0,0 +1,76 @@ +package tc.oc.commons.bukkit.ticket; + +import java.util.List; +import javax.inject.Inject; + +import com.sk89q.minecraft.util.commands.Command; +import com.sk89q.minecraft.util.commands.CommandContext; +import com.sk89q.minecraft.util.commands.CommandException; +import org.bukkit.command.CommandSender; +import tc.oc.api.docs.Game; +import tc.oc.commons.bukkit.commands.CommandUtils; +import tc.oc.commons.core.commands.Commands; +import tc.oc.commons.core.formatting.StringUtils; + +public class TicketCommands implements Commands { + + private final TicketBooth ticketBooth; + + @Inject TicketCommands(TicketBooth ticketBooth) { + this.ticketBooth = ticketBooth; + } + + @Command( + aliases = { "games" }, + desc = "List all the games you can play", + min = 0, + max = 0 + ) + public void games(final CommandContext args, final CommandSender sender) throws CommandException { + ticketBooth.showGames(sender); + } + + @Command( + aliases = { "play", "replay" }, + desc = "Play a game", + usage = "[game]", + min = 0, + max = -1 + ) + public List play(final CommandContext args, final CommandSender sender) throws CommandException { + final String name = args.argsLength() > 0 ? args.getRemainingString(0) : ""; + if(args.getSuggestionContext() != null) { + return StringUtils.complete(name, ticketBooth.allGames(sender).stream().map(Game::name)); + } + + ticketBooth.playGame(CommandUtils.senderToPlayer(sender), name); + return null; + } + + @Command( + aliases = { "leave", "quit" }, + desc = "Leave the game you are currently playing, or waiting to play", + min = 0, + max = 0 + ) + public void leave(final CommandContext args, final CommandSender sender) throws CommandException { + ticketBooth.leaveGame(CommandUtils.senderToPlayer(sender), true); + } + + @Command( + aliases = { "watch" }, + desc = "Spectate a game", + usage = "[game]", + min = 0, + max = -1 + ) + public List watch(final CommandContext args, final CommandSender sender) throws CommandException { + final String name = args.argsLength() > 0 ? args.getRemainingString(0) : ""; + if(args.getSuggestionContext() != null) { + return StringUtils.complete(name, ticketBooth.allGames(sender).stream().map(Game::name)); + } + + ticketBooth.watchGame(CommandUtils.senderToPlayer(sender), name); + return null; + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/ticket/TicketDisplay.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/ticket/TicketDisplay.java new file mode 100644 index 0000000..36f30a5 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/ticket/TicketDisplay.java @@ -0,0 +1,153 @@ +package tc.oc.commons.bukkit.ticket; + +import java.time.Duration; +import java.util.Collections; +import javax.annotation.Nullable; +import javax.inject.Inject; +import javax.inject.Singleton; + +import com.google.common.cache.LoadingCache; +import net.md_5.bungee.api.chat.BaseComponent; +import org.bukkit.boss.BarColor; +import org.bukkit.boss.BarStyle; +import org.bukkit.boss.BossBar; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import tc.oc.api.bukkit.users.BukkitUserStore; +import tc.oc.api.bukkit.users.OnlinePlayers; +import tc.oc.api.docs.Arena; +import tc.oc.api.docs.Game; +import tc.oc.api.docs.Server; +import tc.oc.api.docs.Ticket; +import tc.oc.api.docs.virtual.MatchDoc; +import tc.oc.api.games.ArenaStore; +import tc.oc.api.games.GameStore; +import tc.oc.api.games.TicketStore; +import tc.oc.api.model.ModelDispatcher; +import tc.oc.api.model.ModelListener; +import tc.oc.api.servers.ServerStore; +import tc.oc.commons.bukkit.bossbar.BossBarFactory; +import tc.oc.commons.bukkit.chat.Audiences; +import tc.oc.commons.bukkit.format.GameFormatter; +import tc.oc.commons.bukkit.util.PlayerStates; +import tc.oc.commons.core.plugin.PluginFacet; +import tc.oc.commons.core.util.CacheUtils; +import tc.oc.minecraft.api.scheduler.Tickable; + +/** + * Displays the current state of {@link Game} queues to the {@link Player}s in each queue. + */ +@Singleton +public class TicketDisplay implements ModelListener, Listener, PluginFacet, Tickable { + + private final BukkitUserStore userStore; + private final OnlinePlayers players; + private final PlayerStates playerStates; + private final Audiences audiences; + private final GameFormatter gameFormatter; + private final Server localServer; + private final ServerStore servers; + private final TicketStore tickets; + private final ArenaStore arenas; + private final GameStore games; + + private final LoadingCache bars; + + @Inject TicketDisplay(BukkitUserStore userStore, OnlinePlayers players, PlayerStates playerStates, Audiences audiences, GameFormatter gameFormatter, Server localServer, BossBarFactory bossBarFactory, ServerStore servers, TicketStore tickets, ArenaStore arenas, GameStore games, ModelDispatcher modelDispatcher) { + this.userStore = userStore; + this.players = players; + this.playerStates = playerStates; + this.audiences = audiences; + this.gameFormatter = gameFormatter; + this.localServer = localServer; + this.servers = servers; + this.tickets = tickets; + this.arenas = arenas; + this.games = games; + this.bars = CacheUtils.newCache(game -> bossBarFactory.createRenderedBossBar()); + modelDispatcher.subscribe(this); + } + + private void updateArena(Arena arena) { + final Game game = games.byId(arena.game_id()); + int minPlayers = 0; + if(arena.next_server_id() != null) { + minPlayers = servers.byId(arena.next_server_id()).min_players(); + } + final BaseComponent text; + final double progress; + if(minPlayers > 0 && arena.num_queued() < minPlayers) { + text = gameFormatter.queued(game, minPlayers - arena.num_queued()); + progress = (double) arena.num_queued() / (double) minPlayers; + } else { + text = gameFormatter.joining(game); + progress = 1; + } + bars.getUnchecked(arena).update(text, progress, BarColor.YELLOW, BarStyle.SOLID, Collections.emptySet()); + } + + @HandleModel + public void arenaUpdated(@Nullable Arena before, @Nullable Arena after, Arena latest) { + updateArena(latest); + } + + @HandleModel + public void ticketUpdated(@Nullable Ticket before, @Nullable Ticket after, Ticket latest) { + final Arena arena = arenas.byId(latest.arena_id()); + updateArena(arena); + + final Player player = userStore.find(latest.user()); + if(player != null) { + final BossBar bar = bars.getUnchecked(arena); + if(after != null && after.server_id() == null) { + bar.addPlayer(player); + } else { + bar.removePlayer(player); + } + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onJoin(PlayerJoinEvent event) { + final Ticket ticket = tickets.tryUser(userStore.getUser(event.getPlayer())); + if(ticket != null && ticket.server_id() == null) { + bars.getUnchecked(arenas.byId(ticket.arena_id())).addPlayer(event.getPlayer()); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onQuit(PlayerQuitEvent event) { + final Ticket ticket = tickets.tryUser(userStore.getUser(event.getPlayer())); + if(ticket != null && ticket.server_id() == null) { + bars.getUnchecked(arenas.byId(ticket.arena_id())).removePlayer(event.getPlayer()); + } + } + + @Override + public Duration tickPeriod() { + return Duration.ofSeconds(1); + } + + @Override + public void tick() { + final MatchDoc match = localServer.current_match(); + if(match != null && match.start() != null && !match.join_mid_match() && match.end() == null) { + players.stream() + .filter(playerStates::isObserving) + .forEach(player -> { + final Ticket ticket = tickets.tryUser(userStore.tryUser(player)); + if(ticket != null) { + final Game game = games.byId(arenas.byId(ticket.arena_id()).game_id()); + if(game != null) { + audiences.get(player).sendHotbarMessage(gameFormatter.replayMaybe(game)); + } + } + }); + } + } + +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/ticket/TicketListener.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/ticket/TicketListener.java new file mode 100644 index 0000000..c7c1bbf --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/ticket/TicketListener.java @@ -0,0 +1,76 @@ +package tc.oc.commons.bukkit.ticket; + +import java.util.Objects; +import java.util.logging.Logger; +import javax.annotation.Nullable; +import javax.inject.Inject; +import javax.inject.Singleton; + +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; +import tc.oc.api.bukkit.users.OnlinePlayers; +import tc.oc.api.docs.Server; +import tc.oc.api.docs.Ticket; +import tc.oc.api.games.TicketStore; +import tc.oc.api.minecraft.users.UserStore; +import tc.oc.api.model.ModelDispatcher; +import tc.oc.api.model.ModelListener; +import tc.oc.api.servers.ServerStore; +import tc.oc.commons.bukkit.teleport.PlayerServerChanger; +import tc.oc.commons.core.logging.Loggers; +import tc.oc.commons.core.plugin.PluginFacet; + +/** + * Reacts to changing {@link Ticket}s and joining {@link Player}s, sending players + * to a different server, if their ticket implies they should be elsewhere. + * + * Currently, the dispatch is unconditional, and does not fire a PlayerServerChangeEvent. + * In the future, the event should be fired and cancellation respected. + */ +@Singleton +public class TicketListener implements PluginFacet, Listener, ModelListener { + + private final Logger logger; + private final ServerStore servers; + private final TicketStore tickets; + private final UserStore userStore; + private final Server localServer; + private final OnlinePlayers onlinePlayers; + private final PlayerServerChanger serverChanger; + + @Inject TicketListener(Loggers loggers, ServerStore servers, TicketStore tickets, UserStore userStore, Server localServer, OnlinePlayers onlinePlayers, PlayerServerChanger serverChanger, ModelDispatcher modelDispatcher) { + this.logger = loggers.get(getClass()); + this.servers = servers; + this.tickets = tickets; + this.userStore = userStore; + this.localServer = localServer; + this.onlinePlayers = onlinePlayers; + this.serverChanger = serverChanger; + modelDispatcher.subscribe(this); + } + + private void dispatch(@Nullable Player player, @Nullable Ticket ticket) { + if(player == null || ticket == null) return; + + if(ticket.server_id() != null && !localServer._id().equals(ticket.server_id())) { + final Server server = servers.byId(ticket.server_id()); + logger.info("Sending " + player.getName() + " to server " + server.bungee_name() + " to play a game"); + serverChanger.sendPlayerToServer(player, server, true); + } + } + + @HandleModel + private void onTicketUpdate(@Nullable Ticket before, @Nullable Ticket after, Ticket ticket) { + if(before == null || after == null || !Objects.equals(before.server_id(), after.server_id())) { + dispatch(onlinePlayers.find(ticket.user()), after); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onJoin(PlayerJoinEvent event) { + dispatch(event.getPlayer(), tickets.tryUser(userStore.playerId(event.getPlayer()))); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/trophies/TrophyCase.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/trophies/TrophyCase.java new file mode 100644 index 0000000..ee6d73d --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/trophies/TrophyCase.java @@ -0,0 +1,87 @@ +package tc.oc.commons.bukkit.trophies; + +import java.util.List; +import java.util.Set; +import javax.inject.Inject; +import javax.inject.Singleton; + +import com.google.common.util.concurrent.ListenableFuture; +import org.bukkit.entity.Player; +import org.bukkit.event.EventBus; +import tc.oc.api.bukkit.users.BukkitUserStore; +import tc.oc.api.docs.Trophy; +import tc.oc.api.docs.User; +import tc.oc.api.docs.UserId; +import tc.oc.api.docs.virtual.UserDoc; +import tc.oc.minecraft.scheduler.SyncExecutor; +import tc.oc.api.trophies.TrophyStore; +import tc.oc.api.users.UserService; +import tc.oc.commons.core.stream.Collectors; +import tc.oc.commons.core.util.Streams; + +import static com.google.common.util.concurrent.Futures.immediateFuture; +import static tc.oc.commons.core.concurrent.FutureUtils.mapAsync; +import static tc.oc.commons.core.concurrent.FutureUtils.mapSync; + +/** + * Handles listing, granting, and revoking of {@link Trophy}s from {@link User}s. + */ +@Singleton +public class TrophyCase { + + private final SyncExecutor syncExecutor; + private final TrophyStore trophyStore; + private final BukkitUserStore userStore; + private final UserService userService; + private final EventBus eventBus; + + @Inject TrophyCase(SyncExecutor syncExecutor, TrophyStore trophyStore, BukkitUserStore userStore, UserService userService, EventBus eventBus) { + this.syncExecutor = syncExecutor; + this.trophyStore = trophyStore; + this.userStore = userStore; + this.userService = userService; + this.eventBus = eventBus; + } + + public ListenableFuture> getTrophies(UserId userId) { + return mapSync(userService.find(userId), user -> user.trophy_ids() + .stream() + .map(trophyStore::byId) + .collect(Collectors.toImmutableSet())); + } + + public boolean hasTrophy(User user, Trophy trophy) { + return user.trophy_ids().contains(trophy._id()); + } + + public boolean hasTrophy(Player player, Trophy trophy) { + return hasTrophy(userStore.getUser(player), trophy); + } + + public ListenableFuture hasTrophy(UserId userId, Trophy trophy) { + return mapSync(userService.find(userId), user -> hasTrophy(user, trophy)); + } + + public ListenableFuture giveTrophy(UserId userId, Trophy trophy) { + return grantOrRevoke(userId, trophy, true); + } + + public ListenableFuture revokeTrophy(UserId userId, Trophy trophy) { + return grantOrRevoke(userId, trophy, false); + } + + public ListenableFuture grantOrRevoke(UserId userId, Trophy trophy, boolean grant) { + return mapAsync(userService.find(userId), user -> { + if(grant == user.trophy_ids().contains(trophy._id())) { + return immediateFuture(false); + } + final List trophyIds = (grant ? Streams.append(user.trophy_ids().stream(), trophy._id()) + : Streams.remove(user.trophy_ids().stream(), trophy._id())) + .collect(Collectors.toImmutableList()); + + final ListenableFuture future = userService.update(user, (UserDoc.Trophies) () -> trophyIds); + future.addListener(() -> eventBus.callEvent(new TrophyEvent(user, trophy, grant)), syncExecutor); + return mapSync(future, u -> true); + }); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/trophies/TrophyCommands.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/trophies/TrophyCommands.java new file mode 100644 index 0000000..c6b8135 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/trophies/TrophyCommands.java @@ -0,0 +1,152 @@ +package tc.oc.commons.bukkit.trophies; + +import javax.inject.Inject; + +import com.google.common.collect.ImmutableSet; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.sk89q.minecraft.util.commands.Command; +import com.sk89q.minecraft.util.commands.CommandContext; +import com.sk89q.minecraft.util.commands.CommandException; +import com.sk89q.minecraft.util.commands.CommandPermissions; +import com.sk89q.minecraft.util.commands.NestedCommand; +import net.md_5.bungee.api.ChatColor; +import net.md_5.bungee.api.chat.BaseComponent; +import net.md_5.bungee.api.chat.TranslatableComponent; +import org.bukkit.command.CommandSender; +import tc.oc.api.docs.Trophy; +import tc.oc.api.trophies.TrophyStore; +import tc.oc.api.users.UserSearchResponse; +import tc.oc.commons.bukkit.chat.Audiences; +import tc.oc.commons.bukkit.chat.Paginator; +import tc.oc.commons.bukkit.chat.PlayerComponent; +import tc.oc.commons.bukkit.chat.WarningComponent; +import tc.oc.commons.bukkit.commands.UserFinder; +import tc.oc.commons.bukkit.nick.Identity; +import tc.oc.commons.bukkit.nick.IdentityProvider; +import tc.oc.minecraft.scheduler.MainThreadExecutor; +import tc.oc.commons.core.chat.Component; +import tc.oc.commons.core.commands.CommandFutureCallback; +import tc.oc.commons.core.commands.Commands; +import tc.oc.commons.core.commands.NestedCommands; +import tc.oc.commons.core.commands.TranslatableCommandException; + +public class TrophyCommands implements NestedCommands { + + private final MainThreadExecutor executor; + private final TrophyStore trophyStore; + private final TrophyCase trophyCase; + private final UserFinder userFinder; + private final IdentityProvider identityProvider; + private final Audiences audiences; + + @Inject TrophyCommands(MainThreadExecutor executor, TrophyStore trophyStore, TrophyCase trophyCase, UserFinder userFinder, IdentityProvider identityProvider, Audiences audiences) { + this.executor = executor; + this.trophyStore = trophyStore; + this.trophyCase = trophyCase; + this.userFinder = userFinder; + this.identityProvider = identityProvider; + this.audiences = audiences; + } + + public static class Parent implements Commands { + @Command( + aliases = {"trophies", "trophy"}, + desc = "Commands relating to trophies." + ) + @CommandPermissions(TrophyPermissions.BASE) + @NestedCommand(value = TrophyCommands.class, executeBody = true) + public void trophies(CommandContext args, CommandSender sender) throws CommandException {} + } + + @Command( + aliases = {"list"}, + desc = "List the trophies of a player", + usage = " ", + min = 0, + max = 2 + ) + @CommandPermissions(TrophyPermissions.LIST) + public void list(CommandContext args, CommandSender sender) throws CommandException { + final ListenableFuture future = userFinder.findUser(sender, args, 0, UserFinder.Default.SENDER); + executor.callback( + future, + result -> { + final Identity identity = identityProvider.createIdentity(result); + final boolean self = identity.belongsTo(sender); + + executor.callback( + identity.isDisguised(sender) ? Futures.immediateFuture(ImmutableSet.of()) + : trophyCase.getTrophies(result.user), + trophies -> new Paginator() { + @Override + protected BaseComponent title() { + return new TranslatableComponent( + self ? "trophies.list.self" : "trophies.list.other", + new PlayerComponent(identityProvider.createIdentity(result)) + ); + } + @Override + protected BaseComponent entry(Trophy entry, int index) { + return new Component(entry.name(), ChatColor.AQUA).extra(": ").extra(new Component(entry.description(), ChatColor.GRAY)); + } + }.display(sender, trophies, args.getInteger(1, 1)) + ); + } + ); + } + + @Command( + aliases = {"grant"}, + desc = "Grant a trophy to a player", + usage = "[trophy] ", + min = 1, + max = 2 + ) + @CommandPermissions(TrophyPermissions.MODIFY) + public void grant(CommandContext args, CommandSender sender) throws CommandException { + grantOrRevoke(args, sender, true); + + } + + @Command( + aliases = {"revoke"}, + desc = "Revoke a trophy from a player", + usage = "[trophy] ", + min = 1, + max = 2 + ) + @CommandPermissions(TrophyPermissions.MODIFY) + public void revoke(CommandContext args, CommandSender sender) throws CommandException { + grantOrRevoke(args, sender, false); + } + + private Trophy findTrophy(String name) throws CommandException { + return trophyStore.byName(name).orElseThrow(() -> new TranslatableCommandException("trophies.notFound", name)); + } + + private void grantOrRevoke(CommandContext args, CommandSender sender, boolean give) throws CommandException { + final Trophy trophy = findTrophy(args.getString(0)); + executor.callback( + userFinder.findUser(sender, args, 1, UserFinder.Default.SENDER), + CommandFutureCallback.onSuccess(sender, args, result -> { + executor.callback( + trophyCase.grantOrRevoke(result.user, trophy, give), + CommandFutureCallback.onSuccess(sender, args, changed -> { + final PlayerComponent playerComponent = new PlayerComponent(identityProvider.createIdentity(result)); + final Component trophyComponent = new Component(trophy.name(), ChatColor.GOLD); + + audiences.get(sender).sendMessage( + changed ? new TranslatableComponent(give ? "trophies.grant.success" + : "trophies.revoke.success", + playerComponent, trophyComponent) + : new WarningComponent(give ? "trophies.grant.alreadyOwns" + : "trophies.revoke.doesNotOwn", + playerComponent, trophyComponent) + ); + }) + ); + }) + ); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/trophies/TrophyEvent.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/trophies/TrophyEvent.java new file mode 100644 index 0000000..b2d2a3a --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/trophies/TrophyEvent.java @@ -0,0 +1,51 @@ +package tc.oc.commons.bukkit.trophies; + +import org.bukkit.event.Event; +import org.bukkit.event.HandlerList; +import tc.oc.api.docs.Trophy; +import tc.oc.api.docs.User; +import tc.oc.commons.bukkit.event.UserEvent; + +/** + * Called when a {@link Trophy} is either granted to or revoked from a {@link User}. + */ +public class TrophyEvent extends Event implements UserEvent { + private static final HandlerList handlers = new HandlerList(); + + private final User user; + private final Trophy trophy; + private final boolean grant; + + public TrophyEvent(User user, Trophy trophy, boolean grant) { + this.user = user; + this.trophy = trophy; + this.grant = grant; + } + + public Trophy getTrophy() { + return trophy; + } + + @Override + public User getUser() { + return user; + } + + public boolean isGranting() { + return grant; + } + + public boolean isRevoking() { + return !grant; + } + + @Override + public HandlerList getHandlers() { + return handlers; + } + + public static HandlerList getHandlerList() { + return handlers; + } + +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/trophies/TrophyPermissions.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/trophies/TrophyPermissions.java new file mode 100644 index 0000000..76cfe5a --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/trophies/TrophyPermissions.java @@ -0,0 +1,7 @@ +package tc.oc.commons.bukkit.trophies; + +public interface TrophyPermissions { + String BASE = "ocn.trophies"; + String LIST = BASE + ".list"; + String MODIFY = BASE + ".modify"; +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/users/JoinMessageAnnouncer.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/users/JoinMessageAnnouncer.java new file mode 100644 index 0000000..b242556 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/users/JoinMessageAnnouncer.java @@ -0,0 +1,315 @@ +package tc.oc.commons.bukkit.users; + +import java.util.concurrent.TimeUnit; +import java.util.stream.Stream; +import javax.annotation.Nullable; +import javax.inject.Inject; + +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; +import me.anxuiz.settings.Setting; +import net.md_5.bungee.api.ChatColor; +import net.md_5.bungee.api.chat.BaseComponent; +import net.md_5.bungee.api.chat.TranslatableComponent; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.bukkit.event.EventException; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import tc.oc.api.bukkit.friends.OnlineFriends; +import tc.oc.api.bukkit.users.BukkitUserStore; +import tc.oc.api.bukkit.users.OnlinePlayers; +import tc.oc.api.docs.PlayerId; +import tc.oc.api.docs.Server; +import tc.oc.api.docs.Session; +import tc.oc.api.docs.User; +import tc.oc.api.docs.UserId; +import tc.oc.api.docs.virtual.ServerDoc; +import tc.oc.api.message.MessageListener; +import tc.oc.api.message.MessageQueue; +import tc.oc.api.minecraft.MinecraftService; +import tc.oc.api.servers.ServerStore; +import tc.oc.api.sessions.SessionChange; +import tc.oc.commons.bukkit.chat.Audiences; +import tc.oc.commons.bukkit.chat.NameStyle; +import tc.oc.commons.bukkit.chat.PlayerComponent; +import tc.oc.commons.bukkit.format.ServerFormatter; +import tc.oc.commons.bukkit.nick.Identity; +import tc.oc.commons.bukkit.nick.IdentityProvider; +import tc.oc.commons.bukkit.settings.SettingManagerProvider; +import tc.oc.commons.core.chat.Audience; +import tc.oc.commons.core.chat.Component; +import tc.oc.commons.core.plugin.PluginFacet; +import tc.oc.commons.core.util.Lazy; +import tc.oc.minecraft.scheduler.SyncExecutor; + +import static com.google.common.base.Preconditions.checkArgument; +import static tc.oc.commons.core.IterableUtils.none; +import static tc.oc.commons.core.util.Nullables.first; +import static tc.oc.commons.core.util.Utils.notEqual; + +/** + * Receives {@link SessionChange} messages from the topic exchange and generates + * connect/disconnect/change server announcements. ALL announcements are generated + * from queue messages, even local ones. However, local announcements are synced + * with their respective Bukkit events, to ensure that they appear ordered correctly + * in chat, and names are rendered in the correct state. + * + * The {@link JoinMessageSetting} affects both local and remote announcements, + * except that remote events are never displayed to non-friends. + * + * If join messages are disabled in the plugin config, no announcements will be + * made at all by this service. + * + * When a player changes their nickname, it will appear exactly as if the old identity + * disconnected from the network, and the new identity connected, to viewers who can't + * see through their disguise. If the change is immediate (i.e. nick -i) then both + * events will appear together in chat on the local server. Non-immediate changes will + * only show one message in chat, since the other identity is on a different server. + */ +public class JoinMessageAnnouncer implements MessageListener, Listener, PluginFacet { + + private final MessageQueue queue; + private final OnlineFriends onlineFriends; + private final IdentityProvider identityProvider; + private final SettingManagerProvider playerSettings; + private final JoinMessageConfiguration config; + private final Audiences audiences; + private final ServerStore serverStore; + private final MinecraftService minecraftService; + private final OnlinePlayers onlinePlayers; + private final SyncExecutor syncExecutor; + private final BukkitUserStore userStore; + private final Setting setting; + + // Events involving the local server are delayed until the actual Bukkit event, + // so that the player's name is rendered in the correct state. These caches + // hold the queue message during that delay. + private final Cache pendingJoins = CacheBuilder.newBuilder().expireAfterWrite(10, TimeUnit.SECONDS).build(); + private final Cache pendingQuits = CacheBuilder.newBuilder().expireAfterWrite(10, TimeUnit.SECONDS).build(); + + @Inject JoinMessageAnnouncer(MessageQueue queue, + OnlineFriends onlineFriends, + IdentityProvider identityProvider, + SettingManagerProvider playerSettings, + JoinMessageConfiguration config, + Audiences audiences, + ServerStore serverStore, + MinecraftService minecraftService, + OnlinePlayers onlinePlayers, + SyncExecutor syncExecutor, + BukkitUserStore userStore) { + this.queue = queue; + this.onlineFriends = onlineFriends; + this.identityProvider = identityProvider; + this.playerSettings = playerSettings; + this.config = config; + this.audiences = audiences; + this.serverStore = serverStore; + this.minecraftService = minecraftService; + this.onlinePlayers = onlinePlayers; + this.syncExecutor = syncExecutor; + this.userStore = userStore; + this.setting = JoinMessageSetting.get(); + } + + @Override + public boolean isActive() { + return config.enabled(); + } + + @Override + public void enable() { + queue.subscribe(this, syncExecutor); + queue.bind(SessionChange.class); + } + + @Override + public void disable() { + queue.unsubscribe(this); + } + + @HandleMessage + public void onSessionChange(SessionChange change) { + checkArgument(change.old_session() != null || change.new_session() != null); + + final Server localServer = minecraftService.getLocalServer(); + final boolean localBefore = change.old_session() != null && change.old_session().server_id().equals(localServer._id()); + final boolean localAfter = change.new_session() != null && change.new_session().server_id().equals(localServer._id()); + + if(!localBefore && localAfter && onlinePlayers.find(change.new_session().user()) == null) { + // Joining player is not here yet + pendingJoins.put(change.new_session().user(), change); + } else if(localBefore && !localAfter && onlinePlayers.find(change.old_session().user()) != null) { + // Quitting player hasn't left yet + pendingQuits.put(change.old_session().user(), change); + } else { + announce(change); + } + } + + @EventHandler(priority = EventPriority.HIGHEST) + public void onJoin(PlayerJoinEvent event) { + event.setJoinMessage(null); + final User user = userStore.getUser(event.getPlayer()); + final SessionChange change = pendingJoins.getIfPresent(user); + if(change != null) { + pendingJoins.invalidate(user); + announce(change); + } + } + + @EventHandler(priority = EventPriority.HIGHEST) + public void onQuit(PlayerQuitEvent event) throws EventException { + event.setQuitMessage(null); + final User user = userStore.getUser(event.getPlayer()); + final SessionChange change = pendingQuits.getIfPresent(user); + + event.yield(); + + if(change != null) { + pendingQuits.invalidate(user); + announce(change); + } + } + + private void announce(SessionChange change) { + final ChangedSession finished = new ChangedSession(change.old_session()); + final ChangedSession started = new ChangedSession(change.new_session()); + final PlayerId playerId = first(change.old_session(), change.new_session()).user(); + + // If neither session is from the local server, just loop through + // friends of the player, instead of all players online. + final Stream viewers = + finished.isLocal() || started.isLocal() ? onlinePlayers.all().stream() + : onlineFriends.onlineFriends(playerId); + + // Use lazy messages so we can reuse them for multiple viewers, + // without generating the ones we don't need. Depending on the + // situation, we could end up showing one, two, or all three of + // these messages for a single event. + + final Lazy leaveMessage = Lazy.from(() -> { + final Component c = new Component(ChatColor.YELLOW); + if(!minecraftService.isLocalServer(finished.server)) { + c.extra(ServerFormatter.dark.nameWithDatacenter(finished.server)).extra(" "); + } + return c.extra(new TranslatableComponent("broadcast.leaveMessage", new PlayerComponent(finished.identity, NameStyle.VERBOSE))); + }); + + final Lazy joinMessage = Lazy.from(() -> { + final Component c = new Component(ChatColor.YELLOW); + if(!minecraftService.isLocalServer(started.server)) { + c.extra(ServerFormatter.dark.nameWithDatacenter(started.server)).extra(" "); + } + return c.extra(new TranslatableComponent("broadcast.joinMessage", new PlayerComponent(started.identity, NameStyle.VERBOSE))); + }); + + final Lazy changeMessage = Lazy.from(() -> new Component(ChatColor.YELLOW) + .extra(ServerFormatter.dark.nameWithDatacenter(finished.server)) + .extra(" \u00BB ") + .extra(ServerFormatter.dark.nameWithDatacenter(started.server)) + .extra(" ") + .extra(new TranslatableComponent("broadcast.changeServerMessage", new PlayerComponent(started.identity, NameStyle.VERBOSE)))); + + viewers.forEach(viewerPlayer -> { + if(viewerPlayer.getName().equals(playerId.username())) return; + final Viewer viewer = new Viewer(viewerPlayer); + + if(!viewer.sendChangeServer(finished, started, changeMessage)) { + viewer.sendJoinLeave(leaveMessage, finished, started); + viewer.sendJoinLeave(joinMessage, started, finished); + } + }); + } + + class ChangedSession { + final Identity identity; + final Server server; + + private ChangedSession(@Nullable Session session) { + identity = session == null ? null : identityProvider.createIdentity(session); + server = session == null ? null : serverStore.byId(session.server_id()); + } + + boolean isLocal() { + return server != null && minecraftService.isLocalServer(server); + } + + boolean isVisible() { + // Can't see a session that does not exist + if(server == null) return false; + + // Local sessions are always visible + if(minecraftService.isLocalServer(server)) return true; + + // Private server sessions are never visible + if(server.visibility() == ServerDoc.Visibility.PRIVATE) return false; + + // Sessions from other networks are (configurably) invisible + if(!(config.crossNetwork() || minecraftService.getLocalServer().network().equals(server.network()))) return false; + + // Check family/realm visibility filters + if(!config.families().test(server.family())) return false; + if(none(server.realms(), config.realms())) return false; + + return true; + } + + boolean isVisibleTo(CommandSender viewer) { + // Remote sessions are never visible to non-friends + return isVisible() && (isLocal() || identity.isFriend(viewer)); + } + + boolean belongsTo(Identity identity, CommandSender viewer) { + return isVisibleTo(viewer) && this.identity.isSamePerson(identity, viewer); + } + } + + class Viewer { + final Player player; + final Audience audience; + final JoinMessageSetting.Options jms; + + private Viewer(Player player) { + this.player = player; + this.audience = audiences.get(player); + this.jms = playerSettings.getManager(player).getValue(setting, JoinMessageSetting.Options.class); + } + + boolean sendChangeServer(ChangedSession finished, ChangedSession started, Lazy message) { + // If both sessions are visible, + // and the sessions are on different servers, + // and the sessions appear to belong to the same person, + // then show a "change server" message. + + if(finished.isVisibleTo(player) && started.isVisibleTo(player) && + notEqual(finished.server, started.server) && + finished.identity.isSamePerson(started.identity, player)) { + + if(jms.isAllowed(started.identity.familiarity(player))) { + audience.sendMessage(message.get()); + } + return true; + } + return false; + } + + void sendJoinLeave(Lazy message, ChangedSession session, ChangedSession other) { + // If session A is visible, + // and session B does not appear to be another session belonging to the same person as session A, + // and the viewer's setting allows messages about the owner of session A, + // then inform the viewer about the start/finish of session A. + + if(session.isVisibleTo(player) && + !other.belongsTo(session.identity, player) && + jms.isAllowed(session.identity.familiarity(player))) { + + audience.sendMessage(message.get()); + } + } + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/users/JoinMessageConfiguration.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/users/JoinMessageConfiguration.java new file mode 100644 index 0000000..5f4fe35 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/users/JoinMessageConfiguration.java @@ -0,0 +1,47 @@ +package tc.oc.commons.bukkit.users; + +import java.util.function.Predicate; +import javax.inject.Inject; + +import tc.oc.commons.core.configuration.ConfigUtils; +import tc.oc.commons.core.util.Predicates; +import tc.oc.minecraft.api.configuration.Configuration; +import tc.oc.minecraft.api.configuration.ConfigurationSection; + +public class JoinMessageConfiguration { + + private final ConfigurationSection config; + + @Inject JoinMessageConfiguration(Configuration config) { + this.config = config.getSection("join-messages"); + } + + /** + * Show join/leave/change messages + */ + public boolean enabled() { + return config.getBoolean("enabled", true); + } + + /** + * Show messages from other server networks + */ + public boolean crossNetwork() { + return config.getBoolean("cross-network", true); + } + + /** + * Show messages only from servers in the given families + */ + public Predicate families() { + return ConfigUtils.getStringSetPredicate(config, "families", Predicates.alwaysTrue()); + } + + + /** + * Show messages only from servers in the given realms + */ + public Predicate realms() { + return ConfigUtils.getStringSetPredicate(config, "realms", Predicates.alwaysTrue()); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/users/JoinMessageManifest.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/users/JoinMessageManifest.java new file mode 100644 index 0000000..ee6e44e --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/users/JoinMessageManifest.java @@ -0,0 +1,15 @@ +package tc.oc.commons.bukkit.users; + +import tc.oc.commons.bukkit.settings.SettingBinder; +import tc.oc.commons.core.inject.HybridManifest; +import tc.oc.commons.core.plugin.PluginFacetBinder; + +public class JoinMessageManifest extends HybridManifest { + @Override + protected void configure() { + new SettingBinder(publicBinder()) + .addBinding().toInstance(JoinMessageSetting.get()); + new PluginFacetBinder(binder()) + .add(JoinMessageAnnouncer.class); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/users/JoinMessageSetting.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/users/JoinMessageSetting.java new file mode 100644 index 0000000..bfd387e --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/users/JoinMessageSetting.java @@ -0,0 +1,40 @@ +package tc.oc.commons.bukkit.users; + +import me.anxuiz.settings.Setting; +import me.anxuiz.settings.SettingBuilder; +import me.anxuiz.settings.types.EnumType; +import me.anxuiz.settings.types.Name; +import tc.oc.commons.bukkit.nick.Familiarity; + +public class JoinMessageSetting { + private static final Setting inst = new SettingBuilder() + .name("JoinMessages").alias("jms").alias("jm") + .summary("Join messages displayed to you") + .description("Options:\n" + + "ALL: all messages\n" + + "FRIENDS: messages from friends\n" + + "NONE: no messages\n") + .type(new EnumType<>("Join Message Options", Options.class)) + .defaultValue(Options.FRIENDS) + .get(); + + public static Setting get() { + return inst; + } + + public enum Options { + @Name("all") ALL(Familiarity.PERSON), + @Name("friends") FRIENDS(Familiarity.FRIEND), + @Name("none") NONE(Familiarity.SELF); + + private final Familiarity minimumFamiliarity; + + Options(Familiarity minimumFamiliarity) { + this.minimumFamiliarity = minimumFamiliarity; + } + + public boolean isAllowed(Familiarity familiarity) { + return familiarity.noLessThan(minimumFamiliarity); + } + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/users/PlayerSearchResponse.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/users/PlayerSearchResponse.java new file mode 100644 index 0000000..6062b4f --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/users/PlayerSearchResponse.java @@ -0,0 +1,20 @@ +package tc.oc.commons.bukkit.users; + +import javax.annotation.Nullable; + +import org.bukkit.entity.Player; +import tc.oc.api.users.UserSearchResponse; + +public class PlayerSearchResponse extends UserSearchResponse { + + private final @Nullable Player player; + + public PlayerSearchResponse(UserSearchResponse response, @Nullable Player player) { + super(response.user, response.online, response.disguised, response.last_session, response.last_server); + this.player = player; + } + + public @Nullable Player player() { + return player; + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/util/PermissionUtils.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/util/PermissionUtils.java new file mode 100644 index 0000000..e19ddee --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/util/PermissionUtils.java @@ -0,0 +1,19 @@ +package tc.oc.commons.bukkit.util; + +import java.util.Map; + +import org.bukkit.permissions.Permissible; +import org.bukkit.permissions.PermissionAttachment; +import tc.oc.api.util.Permissions; + +public abstract class PermissionUtils { + public static boolean isStaff(Permissible permissible) { + return permissible.hasPermission(Permissions.STAFF); + } + + public static void setPermissions(PermissionAttachment attachment, Map permissions) { + for(Map.Entry entry : permissions.entrySet()) { + attachment.setPermission(entry.getKey(), entry.getValue()); + } + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/util/PlayerStates.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/util/PlayerStates.java new file mode 100644 index 0000000..c5a0c5f --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/util/PlayerStates.java @@ -0,0 +1,30 @@ +package tc.oc.commons.bukkit.util; + +import org.bukkit.entity.Player; +import org.bukkit.metadata.MetadataValue; + +import javax.annotation.Nullable; + +/** + * Mechanism for retrieving basic state data about a match player + * from a {@link Player}. All values are set by PGM using {@link MetadataValue}s. + */ +public interface PlayerStates { + + boolean isDead(Player player); + + default boolean isAlive(Player player) { + return !isDead(player); + } + + void setDead(Player player, @Nullable Boolean value); + + boolean isParticipating(Player player); + + default boolean isObserving(Player player) { + return !isParticipating(player); + } + + void setParticipating(Player player, @Nullable Boolean value); + +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/util/PlayerStatesImpl.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/util/PlayerStatesImpl.java new file mode 100644 index 0000000..b188271 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/util/PlayerStatesImpl.java @@ -0,0 +1,59 @@ +package tc.oc.commons.bukkit.util; + +import org.bukkit.GameMode; +import org.bukkit.entity.Player; +import org.bukkit.metadata.FixedMetadataValue; +import org.bukkit.metadata.MetadataValue; +import org.bukkit.plugin.Plugin; + +import javax.annotation.Nullable; +import javax.inject.Inject; +import javax.inject.Singleton; + +@Singleton +public class PlayerStatesImpl implements PlayerStates { + + private final static String DEAD_KEY = "isDead"; + private final static String PARTICIPATING_KEY = "isParticipating"; + + private final Plugin plugin; + + @Inject + PlayerStatesImpl(Plugin plugin) { + this.plugin = plugin; + } + + private boolean get(Player player, String key, boolean fallback) { + final MetadataValue value = player.getMetadata(key, plugin); + return value != null ? value.asBoolean() : fallback; + } + + private void set(Player player, String key, @Nullable Boolean value) { + if(value != null) { + player.setMetadata(key, new FixedMetadataValue(plugin, value)); + } else { + player.removeMetadata(key, plugin); + } + } + + @Override + public boolean isDead(Player player) { + return get(player, DEAD_KEY, player.isDead()); + } + + @Override + public void setDead(Player player, @Nullable Boolean dead) { + set(player, DEAD_KEY, dead); + } + + @Override + public boolean isParticipating(Player player) { + return get(player, PARTICIPATING_KEY, player.getGameMode() == GameMode.SURVIVAL || player.getGameMode() == GameMode.ADVENTURE); + } + + @Override + public void setParticipating(Player player, @Nullable Boolean value) { + set(player, PARTICIPATING_KEY, value); + } + +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/util/SyncPlayerExecutorFactory.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/util/SyncPlayerExecutorFactory.java new file mode 100644 index 0000000..1701ae7 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/util/SyncPlayerExecutorFactory.java @@ -0,0 +1,75 @@ +package tc.oc.commons.bukkit.util; + +import java.util.UUID; +import java.util.concurrent.Executor; +import javax.inject.Inject; + +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import tc.oc.api.bukkit.users.OnlinePlayers; +import tc.oc.api.docs.SimpleUserId; +import tc.oc.api.docs.UserId; +import tc.oc.minecraft.scheduler.SyncExecutor; +import tc.oc.minecraft.scheduler.MainThreadExecutor; +import tc.oc.commons.core.concurrent.ContextualExecutor; +import tc.oc.commons.core.concurrent.ContextualExecutorImpl; + +/** + * Creates {@link ContextualExecutor}s for {@link Player}s that will only execute + * tasks when the player is online. If the same player reconnects, their current + * {@link Player} instance will always be passed to the tasks. + */ +public class SyncPlayerExecutorFactory { + + private final MainThreadExecutor mainThreadExecutor; + private final SyncExecutor syncExecutor; + private final OnlinePlayers onlinePlayers; + + @Inject SyncPlayerExecutorFactory(MainThreadExecutor mainThreadExecutor, SyncExecutor syncExecutor, OnlinePlayers onlinePlayers) { + this.mainThreadExecutor = mainThreadExecutor; + this.syncExecutor = syncExecutor; + this.onlinePlayers = onlinePlayers; + } + + public ContextualExecutor mainThread(T sender) { + return create(sender, mainThreadExecutor); + } + + public ContextualExecutor mainThread(Player player) { + return create(player, mainThreadExecutor); + } + + public ContextualExecutor mainThread(UserId userId) { + return create(userId, mainThreadExecutor); + } + + public ContextualExecutor queued(T sender) { + return create(sender, syncExecutor); + } + + public ContextualExecutor queued(Player player) { + return create(player, syncExecutor); + } + + public ContextualExecutor queued(UserId userId) { + return create(userId, syncExecutor); + } + + public ContextualExecutor create(T sender, Executor executor) { + if(sender instanceof Player) { + return (ContextualExecutor) create((Player) sender, executor); + } else { + return new ContextualExecutorImpl<>(() -> sender, executor); + } + } + + public ContextualExecutor create(Player player, Executor executor) { + final UUID uuid = player.getUniqueId(); + return new ContextualExecutorImpl<>(() -> onlinePlayers.find(uuid), executor); + } + + public ContextualExecutor create(UserId userId, Executor executor) { + final UserId simpleUserId = SimpleUserId.copyOf(userId); + return new ContextualExecutorImpl<>(() -> onlinePlayers.find(simpleUserId), executor); + } +} \ No newline at end of file diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/whisper/WhisperCommands.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/whisper/WhisperCommands.java new file mode 100644 index 0000000..3a62095 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/whisper/WhisperCommands.java @@ -0,0 +1,106 @@ +package tc.oc.commons.bukkit.whisper; + +import javax.inject.Inject; + +import com.sk89q.minecraft.util.commands.Command; +import com.sk89q.minecraft.util.commands.CommandContext; +import com.sk89q.minecraft.util.commands.CommandException; +import com.sk89q.minecraft.util.commands.CommandPermissions; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import tc.oc.api.bukkit.users.BukkitUserStore; +import tc.oc.api.docs.User; +import tc.oc.api.docs.Whisper; +import tc.oc.api.exceptions.NotFound; +import tc.oc.api.whispers.WhisperService; +import tc.oc.commons.bukkit.chat.Audiences; +import tc.oc.commons.bukkit.commands.CommandUtils; +import tc.oc.commons.bukkit.commands.UserFinder; +import tc.oc.commons.bukkit.nick.Identity; +import tc.oc.commons.bukkit.nick.IdentityProvider; +import tc.oc.minecraft.scheduler.MainThreadExecutor; +import tc.oc.commons.core.chat.Audience; +import tc.oc.commons.core.commands.CommandFutureCallback; +import tc.oc.commons.core.commands.Commands; + +public class WhisperCommands implements Commands { + private final BukkitUserStore userStore; + private final IdentityProvider identityProvider; + private final UserFinder userFinder; + private final MainThreadExecutor executor; + private final Audiences audiences; + private final WhisperService whisperService; + private final WhisperSender whisperSender; + private final WhisperFormatter formatter; + + @Inject WhisperCommands(BukkitUserStore userStore, + IdentityProvider identityProvider, + UserFinder userFinder, + MainThreadExecutor executor, + Audiences audiences, + WhisperSender whisperSender, + WhisperService whisperService, + WhisperFormatter formatter) { + this.userStore = userStore; + this.identityProvider = identityProvider; + this.userFinder = userFinder; + this.executor = executor; + this.audiences = audiences; + this.whisperSender = whisperSender; + this.whisperService = whisperService; + this.formatter = formatter; + } + + @Command( + aliases = {"msg", "message", "whisper", "pm", "tell", "dm"}, + usage = " ", + desc = "Private message a user", + min = 2, + max = -1 + ) + @CommandPermissions("projectares.msg") + public void message(final CommandContext args, final CommandSender sender) throws CommandException { + final Player player = CommandUtils.senderToPlayer(sender); + final Identity from = identityProvider.currentIdentity(player); + final String content = args.getJoinedStrings(1); + + executor.callback( + userFinder.findUser(sender, args, 0), + CommandFutureCallback.onSuccess(sender, args, response -> { + whisperSender.send(sender, from, identityProvider.createIdentity(response), content); + }) + ); + } + + @Command( + aliases = {"reply", "r"}, + usage = "", + desc = "Reply to last user", + min = 1, + max = -1 + ) + @CommandPermissions("projectares.msg") + public void reply(final CommandContext args, final CommandSender sender) throws CommandException { + final Player player = CommandUtils.senderToPlayer(sender); + final User user = userStore.getUser(player); + final Audience audience = audiences.get(sender); + final String content = args.getJoinedStrings(0); + + executor.callback( + whisperService.forReply(user), + CommandFutureCallback.onSuccess(sender, args, original -> { + final Identity from, to; + if(user.equals(original.sender_uid())) { + // Follow-up of previously sent message + from = formatter.senderIdentity(original); + to = formatter.recipientIdentity(original); + } else { + // Reply to received message + from = formatter.recipientIdentity(original); + to = formatter.senderIdentity(original); + } + whisperSender.send(sender, from, to, content); + }).onFailure(NotFound.class, e -> formatter.noReply(sender)) + ); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/whisper/WhisperDispatcher.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/whisper/WhisperDispatcher.java new file mode 100644 index 0000000..13a8529 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/whisper/WhisperDispatcher.java @@ -0,0 +1,197 @@ +package tc.oc.commons.bukkit.whisper; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import javax.inject.Inject; + +import com.google.common.collect.Lists; +import com.google.common.util.concurrent.ListenableFuture; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import tc.oc.api.bukkit.users.OnlinePlayers; +import tc.oc.api.docs.BasicModel; +import tc.oc.api.docs.PlayerId; +import tc.oc.api.docs.Server; +import tc.oc.api.docs.Whisper; +import tc.oc.api.docs.virtual.WhisperDoc; +import tc.oc.api.message.MessageListener; +import tc.oc.api.message.MessageQueue; +import tc.oc.api.message.types.ModelUpdate; +import tc.oc.api.model.IdFactory; +import tc.oc.api.users.UserService; +import tc.oc.api.whispers.WhisperService; +import tc.oc.commons.bukkit.event.UserLoginEvent; +import tc.oc.commons.bukkit.nick.Identity; +import tc.oc.commons.bukkit.settings.SettingManagerProvider; +import tc.oc.commons.core.plugin.PluginFacet; +import tc.oc.commons.core.scheduler.Scheduler; +import tc.oc.minecraft.scheduler.MainThreadExecutor; + +public class WhisperDispatcher implements WhisperSender, Listener, MessageListener, PluginFacet { + + // Mark a whisper as read if the player is still online + // this long after first seeing it. This prevents a player + // from missing a message because they disconnected right + // at the same time they received it. + private static final Duration MARK_READ_DELAY = Duration.ofSeconds(3); + + private final MessageQueue queue; + private final OnlinePlayers onlinePlayers; + private final MainThreadExecutor executor; + private final Scheduler scheduler; + private final WhisperService whisperService; + private final WhisperFormatter formatter; + private final Server localServer; + private final IdFactory idFactory; + private final SettingManagerProvider playerSettings; + private final UserService userService; + + @Inject WhisperDispatcher(MessageQueue queue, + OnlinePlayers onlinePlayers, + MainThreadExecutor executor, + Scheduler scheduler, + WhisperService whisperService, + WhisperFormatter formatter, + Server localServer, + IdFactory idFactory, + SettingManagerProvider playerSettings, + UserService userService) { + this.queue = queue; + this.onlinePlayers = onlinePlayers; + this.executor = executor; + this.scheduler = scheduler; + this.whisperService = whisperService; + this.formatter = formatter; + this.localServer = localServer; + this.idFactory = idFactory; + this.playerSettings = playerSettings; + this.userService = userService; + } + + @Override + public void enable() { + queue.bind(ModelUpdate.class); + queue.subscribe(this, executor); + } + + @Override + public void disable() { + queue.unsubscribe(this); + } + + @Override + public void send(CommandSender sender, Identity from, Identity to, String content) { + executor.callback(userService.find(to.getPlayerId()), user -> { + final WhisperSettings.Options setting = (WhisperSettings.Options) playerSettings.getManager(user).getValue(WhisperSettings.receive()); + if(setting.canSend(sender, to)) { + final ListenableFuture future = whisperService.update(new Out(from, to, content)); + executor.callback(future, whisper -> formatter.send(sender, whisper)); + } else { + formatter.blocked(sender, to); + } + }); + } + + private void markRead(Player player, Runnable block) { + scheduler.createDelayedTask(MARK_READ_DELAY, () -> { + // Once willBeOnline goes false, it stays false forever, + // whereas isOnline can become true again if the player + // reconnects. + if(player.willBeOnline()) { + block.run(); + } + }); + } + + @HandleMessage + private void receive(ModelUpdate update) { + final Whisper whisper = update.document(); + if(whisper.delivered()) return; + + onlinePlayers.byUserId(whisper.recipient_uid()).ifPresent(player -> { + formatter.receive(player, whisper); + markRead(player, () -> + whisperService.update(new Ack(whisper._id())) + ); + }); + } + + /** + * Make sure this runs after the welcome message in the lobby + */ + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void login(UserLoginEvent event) { + final List whispers = event.response().whispers(); + if(!whispers.isEmpty()) { + final Player player = event.getPlayer(); + whispers.forEach(whisper -> formatter.receive(player, whisper)); + markRead(player, () -> + whisperService.updateMulti(Lists.transform(whispers, whisper -> new Ack(whisper._id()))) + ); + } + } + + private class Out extends BasicModel implements Whisper { + + final Identity from, to; + final String content; + + private Out(Identity from, Identity to, String content) { + super(idFactory.newId()); + this.from = from; + this.to = to; + this.content = content; + } + + @Override public String family() { + return localServer.family(); + } + + @Override public String server_id() { + return localServer._id(); + } + + @Override public Instant sent() { + return Instant.now(); + } + + @Override public boolean delivered() { + return false; + } + + @Override public PlayerId sender_uid() { + return from.getPlayerId(); + } + + @Override public String sender_nickname() { + return from.getNickname(); + } + + @Override public PlayerId recipient_uid() { + return to.getPlayerId(); + } + + @Override public String recipient_specified() { + return to.getNickname(); + } + + @Override public String content() { + return content; + } + } + + private class Ack extends BasicModel implements WhisperDoc.Delivery { + public Ack(String _id) { + super(_id); + } + + @Override + public boolean delivered() { + return true; + } + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/whisper/WhisperFormatter.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/whisper/WhisperFormatter.java new file mode 100644 index 0000000..c030369 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/whisper/WhisperFormatter.java @@ -0,0 +1,145 @@ +package tc.oc.commons.bukkit.whisper; + +import java.time.Duration; +import java.time.Instant; +import javax.inject.Inject; + +import net.md_5.bungee.api.ChatColor; +import net.md_5.bungee.api.chat.TranslatableComponent; +import org.bukkit.Sound; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import tc.oc.api.docs.Server; +import tc.oc.api.docs.Whisper; +import tc.oc.api.servers.ServerStore; +import tc.oc.commons.bukkit.chat.Audiences; +import tc.oc.commons.bukkit.chat.BukkitSound; +import tc.oc.commons.bukkit.chat.ConsoleAudience; +import tc.oc.commons.bukkit.chat.NameStyle; +import tc.oc.commons.bukkit.chat.PlayerComponent; +import tc.oc.commons.bukkit.chat.UserTextComponent; +import tc.oc.commons.bukkit.chat.WarningComponent; +import tc.oc.commons.bukkit.format.MiscFormatter; +import tc.oc.commons.bukkit.format.ServerFormatter; +import tc.oc.commons.bukkit.nick.Identity; +import tc.oc.commons.bukkit.nick.IdentityProvider; +import tc.oc.commons.bukkit.settings.SettingManagerProvider; +import tc.oc.commons.core.chat.Audience; +import tc.oc.commons.core.chat.Component; +import tc.oc.commons.core.formatting.PeriodFormats; +import tc.oc.commons.core.util.Comparables; + +public class WhisperFormatter { + + private static final Duration OLD_MESSAGE = Duration.ofSeconds(10); + private static final BukkitSound MESSAGE_SOUND = new BukkitSound(Sound.ENTITY_PLAYER_LEVELUP, 1, 2); + + private final IdentityProvider identities; + private final MiscFormatter miscFormatter; + private final ServerFormatter serverFormatter; + private final ServerStore serverStore; + private final Server localServer; + private final SettingManagerProvider playerSettings; + private final Audiences audiences; + private final ConsoleAudience consoleAudience; + + @Inject WhisperFormatter(IdentityProvider identities, MiscFormatter miscFormatter, ServerStore serverStore, Server localServer, SettingManagerProvider playerSettings, Audiences audiences, ConsoleAudience consoleAudience) { + this.identities = identities; + this.miscFormatter = miscFormatter; + this.serverStore = serverStore; + this.localServer = localServer; + this.playerSettings = playerSettings; + this.audiences = audiences; + this.consoleAudience = consoleAudience; + this.serverFormatter = ServerFormatter.dark; + } + + public Identity senderIdentity(Whisper whisper) { + return identities.createIdentity(whisper.sender_uid(), whisper.sender_nickname()); + } + + public Identity recipientIdentity(Whisper whisper) { + return identities.createIdentity(whisper.recipient_uid(), whisper.recipient_specified()); + } + + private Component prefix() { + return new Component(miscFormatter.typePrefix("PM"), ChatColor.GRAY); + } + + public void send(CommandSender viewer, Whisper whisper) { + final Identity sender = senderIdentity(whisper); + final Identity recipient = recipientIdentity(whisper); + final Audience audience = audiences.get(viewer); + + final Component display = prefix() + .extra(new TranslatableComponent(sender.getNickname() == null ? "privateMessage.to" + : "privateMessage.from.to", + new PlayerComponent(sender, NameStyle.VERBOSE), + new PlayerComponent(recipient, NameStyle.VERBOSE))) + .extra(": ") + .extra(new Component(new UserTextComponent(sender, whisper.content()), ChatColor.WHITE)); + + audience.sendMessage(display); + consoleAudience.sendMessage(display); + } + + public void receive(Player viewer, Whisper whisper) { + final Identity sender = senderIdentity(whisper); + final Identity recipient = recipientIdentity(whisper); + final Audience audience = audiences.get(viewer); + final boolean local = whisper.server_id().equals(localServer._id()); + + final Component from = new Component(); + if(!local) { + // Show sender server if not local. There is an edge case where this reveals nicked players: + // if a player uses /reply to send a message from their real identity while they are nicked, + // the recipient will know they are nicked because they are not reported as online, and they + // will also know what server they are on. The former is unavoidable. The latter could be + // avoided by saving a flag in the message indicating that the sender was disguised, but + // that probably isn't worth the trouble. We might do it when we move PMs to the API. + from.extra(serverFormatter.nameWithDatacenter(serverStore.byId(whisper.server_id()))).extra(" "); + } + from.extra(new PlayerComponent(sender, NameStyle.VERBOSE)); + + // Show recipient identity if it is nicked OR if it is not the recipient's current identity + String key = "privateMessage.from"; + if(recipient.getNickname() != null || !recipient.isCurrent()) { + key += ".to"; + } + + final Duration age = Duration.between(whisper.sent(), Instant.now()); + if(Comparables.greaterThan(age, OLD_MESSAGE)) { + // If message is old, show the time + key += ".time"; + } else { + // If message is new, play a sound + if(playerSettings.getManager(viewer) + .getValue(WhisperSettings.sound(), WhisperSettings.Options.class) + .isAllowed(sender.familiarity(viewer))) { + audience.playSound(MESSAGE_SOUND); + } + } + + final Component display = prefix() + .extra(new TranslatableComponent(key, + from, + new PlayerComponent(recipient, NameStyle.VERBOSE), + new Component(new TranslatableComponent("time.ago", PeriodFormats.briefNaturalApproximate(age)), ChatColor.GOLD))) + .extra(": ") + .extra(new Component(new UserTextComponent(whisper.content()), ChatColor.WHITE)); + + audience.sendMessage(display); + if(!local) { + consoleAudience.sendMessage(display); + } + } + + public void blocked(CommandSender viewer, Identity recipient) { + audiences.get(viewer).sendMessage(new WarningComponent("command.message.blockedNoPermissions", + new PlayerComponent(recipient, NameStyle.VERBOSE))); + } + + public void noReply(CommandSender viewer) { + audiences.get(viewer).sendMessage(new WarningComponent("command.reply.noMessages")); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/whisper/WhisperManifest.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/whisper/WhisperManifest.java new file mode 100644 index 0000000..38bd52d --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/whisper/WhisperManifest.java @@ -0,0 +1,26 @@ +package tc.oc.commons.bukkit.whisper; + +import javax.inject.Singleton; + +import tc.oc.commons.bukkit.settings.SettingBinder; +import tc.oc.commons.core.inject.HybridManifest; +import tc.oc.commons.core.plugin.PluginFacetBinder; + +public class WhisperManifest extends HybridManifest { + @Override + protected void configure() { + final SettingBinder settings = new SettingBinder(publicBinder()); + settings.addBinding().toInstance(WhisperSettings.receive()); + settings.addBinding().toInstance(WhisperSettings.sound()); + + bind(WhisperFormatter.class); + bind(WhisperCommands.class).in(Singleton.class); + bind(WhisperDispatcher.class).in(Singleton.class); + bind(WhisperSender.class).to(WhisperDispatcher.class); + expose(WhisperSender.class); + + final PluginFacetBinder facets = new PluginFacetBinder(binder()); + facets.add(WhisperDispatcher.class); + facets.add(WhisperCommands.class); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/whisper/WhisperSender.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/whisper/WhisperSender.java new file mode 100644 index 0000000..9abcd7e --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/whisper/WhisperSender.java @@ -0,0 +1,8 @@ +package tc.oc.commons.bukkit.whisper; + +import org.bukkit.command.CommandSender; +import tc.oc.commons.bukkit.nick.Identity; + +public interface WhisperSender { + void send(CommandSender sender, Identity from, Identity to, String content); +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/whisper/WhisperSettings.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/whisper/WhisperSettings.java new file mode 100644 index 0000000..9a22e68 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/whisper/WhisperSettings.java @@ -0,0 +1,70 @@ +package tc.oc.commons.bukkit.whisper; + +import me.anxuiz.settings.Setting; +import me.anxuiz.settings.SettingBuilder; +import me.anxuiz.settings.types.EnumType; +import me.anxuiz.settings.types.Name; +import org.bukkit.command.CommandSender; +import tc.oc.commons.bukkit.nick.Familiarity; +import tc.oc.commons.bukkit.nick.Identity; + +public class WhisperSettings { + + // Common values between private message settings + private static final Options defaultValue = Options.ALL; + private static final EnumType enumType = new EnumType("Private Message Options", Options.class); + private static final String description = "Options:\n" + + "ALL: everybody\n" + + "FRIENDS: friends only\n" + + "NONE: nobody"; + + /** + * Whom users can receive private messages from. + */ + private static final Setting recieve = new SettingBuilder() + .name("PrivateMessages") + .alias("msg").alias("message").alias("messages").alias("pm").alias("pmr") + .description(description).type(enumType).defaultValue(defaultValue) + .summary("Who can send you private messages").get(); + + public static Setting receive() { + return recieve; + } + + // Permission that allows you to send to anyone + public static final String SEND_OVERRIDE_PERMISSION = "projectares.msg.override"; + + /** + * Whether a user gets a sound notification when a private message arrives. + */ + private static final Setting sound = new SettingBuilder() + .name("PrivateMessageSounds") + .alias("sounds").alias("pmsound").alias("pms") + .description(description).type(enumType).defaultValue(defaultValue) + .summary("Whether you hear a sound when you receive a private message").get(); + + public static Setting sound() { + return sound; + } + + public enum Options { + @Name("all") ALL(Familiarity.PERSON), + @Name("friends") FRIENDS(Familiarity.FRIEND), + @Name("none") NONE(Familiarity.SELF); + + private final Familiarity minimumFamiliarity; + + Options(Familiarity minimumFamiliarity) { + this.minimumFamiliarity = minimumFamiliarity; + } + + public boolean isAllowed(Familiarity familiarity) { + return familiarity.noLessThan(minimumFamiliarity); + } + + public boolean canSend(CommandSender sender, Identity recipient) { + return sender.hasPermission(SEND_OVERRIDE_PERMISSION) || + isAllowed(recipient.familiarity(sender)); + } + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/whitelist/Whitelist.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/whitelist/Whitelist.java new file mode 100644 index 0000000..1118ee1 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/whitelist/Whitelist.java @@ -0,0 +1,106 @@ +package tc.oc.commons.bukkit.whitelist; + +import java.util.HashSet; +import java.util.Set; +import javax.inject.Inject; +import javax.inject.Singleton; + +import com.google.common.collect.ForwardingSet; +import net.md_5.bungee.api.chat.TranslatableComponent; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerLoginEvent; +import tc.oc.api.bukkit.users.BukkitUserStore; +import tc.oc.api.bukkit.users.OnlinePlayers; +import tc.oc.api.docs.PlayerId; +import tc.oc.api.docs.Server; +import tc.oc.commons.bukkit.chat.ComponentRenderContext; +import tc.oc.commons.bukkit.event.UserLoginEvent; +import tc.oc.commons.bukkit.event.WhitelistStateChangeEvent; +import tc.oc.commons.core.plugin.PluginFacet; + +@Singleton +public class Whitelist extends ForwardingSet implements PluginFacet, Listener { + public static final String EDIT_PERM = "whitelist.edit"; + public static final String BYPASS_PERM = "whitelist.bypass"; + + private final Server localServer; + private final BukkitUserStore userStore; + private final OnlinePlayers onlinePlayers; + private final ComponentRenderContext renderer; + + private boolean enabled; + private final Set whitelist = new HashSet<>(); + + @Inject Whitelist(Server localServer, BukkitUserStore userStore, OnlinePlayers onlinePlayers, ComponentRenderContext renderer) { + this.localServer = localServer; + this.userStore = userStore; + this.onlinePlayers = onlinePlayers; + this.renderer = renderer; + } + + @Override + protected Set delegate() { + return whitelist; + } + + @Override + public void enable() { + reset(); + setEnabled(localServer.whitelist_enabled()); + } + + public void reset() { + clear(); + if(localServer.team() != null) { + addAll(localServer.team().members()); + } + } + + public boolean isEnabled() { + return enabled; + } + + public boolean isWhitelisted(Player player) { + return !enabled || + player.isOp() || + player.hasPermission(BYPASS_PERM) || + whitelist.contains(userStore.getUser(player)); + } + + public void setEnabled(boolean yes) { + enabled = yes; + Bukkit.getServer().getPluginManager().callEvent(new WhitelistStateChangeEvent(enabled)); + } + + public int addAllOnline() { + int count = 0; + for(Player player : onlinePlayers.all()) { + if(!player.hasPermission(BYPASS_PERM) && add(userStore.getUser(player))) { + count++; + } + } + return count; + } + + public int kickAll() { + int count = 0; + for(Player player : onlinePlayers.all()) { + if(!isWhitelisted(player)) { + player.kickPlayer(renderer.renderLegacy(new TranslatableComponent("whitelist.kicked"), player)); + count++; + } + } + return count; + } + + @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true) + public void onLogin(UserLoginEvent event) { + if(!isWhitelisted(event.getPlayer())) { + event.disallow(PlayerLoginEvent.Result.KICK_WHITELIST, new TranslatableComponent("whitelist.kicked")); + } + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/whitelist/WhitelistCommands.java b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/whitelist/WhitelistCommands.java new file mode 100644 index 0000000..6850b00 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/commons/bukkit/whitelist/WhitelistCommands.java @@ -0,0 +1,207 @@ +package tc.oc.commons.bukkit.whitelist; + +import java.util.Iterator; +import javax.inject.Inject; + +import com.sk89q.minecraft.util.commands.Command; +import com.sk89q.minecraft.util.commands.CommandContext; +import com.sk89q.minecraft.util.commands.CommandException; +import com.sk89q.minecraft.util.commands.CommandPermissions; +import com.sk89q.minecraft.util.commands.NestedCommand; +import net.md_5.bungee.api.ChatColor; +import net.md_5.bungee.api.chat.TranslatableComponent; +import org.bukkit.command.CommandSender; +import tc.oc.api.docs.PlayerId; +import tc.oc.minecraft.scheduler.SyncExecutor; +import tc.oc.commons.bukkit.chat.Audiences; +import tc.oc.commons.bukkit.chat.ListComponent; +import tc.oc.commons.bukkit.chat.NameStyle; +import tc.oc.commons.bukkit.chat.PlayerComponent; +import tc.oc.commons.bukkit.commands.UserFinder; +import tc.oc.commons.bukkit.format.MiscFormatter; +import tc.oc.commons.bukkit.nick.IdentityProvider; +import tc.oc.commons.core.chat.Audience; +import tc.oc.commons.core.chat.Component; +import tc.oc.commons.core.commands.CommandFutureCallback; +import tc.oc.commons.core.commands.Commands; +import tc.oc.commons.core.commands.NestedCommands; +import tc.oc.commons.core.commands.TranslatableCommandException; + +public class WhitelistCommands implements NestedCommands { + + public static class Parent implements Commands { + @Command( + aliases = {"whitelist", "wl"}, + desc = "Commands to manipulate the player whitelist", + min = 1, + max = -1 + ) + @NestedCommand(WhitelistCommands.class) + @CommandPermissions(Whitelist.EDIT_PERM) + public void whitelist() {} + } + + private final Whitelist whitelist; + private final SyncExecutor syncExecutor; + private final Audiences audiences; + private final MiscFormatter misc; + private final IdentityProvider identities; + private final UserFinder userFinder; + + @Inject WhitelistCommands(Whitelist whitelist, SyncExecutor syncExecutor, Audiences audiences, MiscFormatter misc, IdentityProvider identities, UserFinder userFinder) { + this.whitelist = whitelist; + this.syncExecutor = syncExecutor; + this.audiences = audiences; + this.misc = misc; + this.identities = identities; + this.userFinder = userFinder; + } + + @Command( + aliases = {"status", "state"}, + desc = "Check if the whitelist is on or off", + min = 0, + max = 0 + ) + public void status(CommandContext args, CommandSender sender) throws CommandException { + audiences.get(sender).sendMessage( + new TranslatableComponent( + "whitelist.status", + misc.abled(whitelist.isEnabled()) + ) + ); + } + + @Command( + aliases = {"on", "enable"}, + desc = "Enable the whitelist", + min = 0, + max = 0 + ) + public void enable(CommandContext args, CommandSender sender) throws CommandException { + whitelist.setEnabled(true); + status(args, sender); + } + + @Command( + aliases = {"off", "disable"}, + desc = "Disable the whitelist", + min = 0, + max = 0 + ) + public void disable(CommandContext args, CommandSender sender) throws CommandException { + whitelist.setEnabled(false); + status(args, sender); + } + + @Command( + aliases = {"list", "show"}, + desc = "Show the complete whitelist", + min = 0, + max = 0 + ) + public void list(CommandContext args, CommandSender sender) throws CommandException { + final Audience audience = audiences.get(sender); + if(whitelist.isEmpty()) { + audience.sendMessage(new TranslatableComponent("whitelist.empty")); + return; + } + + final ListComponent list = new ListComponent( + whitelist.stream() + .map(identities::currentIdentity) + .map(identity -> new PlayerComponent(identity, NameStyle.FANCY)) + ); + audience.sendMessage( + new Component() + .extra(new TranslatableComponent("whitelist.size", new Component(whitelist.size(), ChatColor.AQUA))) + .extra(": ") + .extra(list) + ); + } + + @Command( + aliases = {"reset", "clear"}, + desc = "Reset whitelist members to default", + min = 0, + max = 0 + ) + public void clear(CommandContext args, final CommandSender sender) throws CommandException { + whitelist.reset(); + audiences.get(sender).sendMessage(new TranslatableComponent(whitelist.isEmpty() ? "whitelist.empty" : "whitelist.default")); + } + + @Command( + aliases = {"add"}, + desc = "Add a player to the whitelist", + usage = "", + min = 1, + max = 1 + ) + public void add(CommandContext args, final CommandSender sender) throws CommandException { + syncExecutor.callback( + userFinder.findUser(sender, args, 0), + CommandFutureCallback.onSuccess(sender, args, result -> { + whitelist.add(result.user); + audiences.get(sender).sendMessage( + new TranslatableComponent( + "whitelist.add", + new PlayerComponent(identities.currentIdentity(result.user), NameStyle.FANCY) + ) + ); + }) + ); + } + + @Command( + aliases = {"remove"}, + desc = "Remove a player from the whitelist", + usage = "", + min = 1, + max = 1 + ) + public void remove(CommandContext args, final CommandSender sender) throws CommandException { + final String username = args.getString(0); + for(Iterator iter = whitelist.iterator(); iter.hasNext();) { + PlayerId playerId = iter.next(); + if(username.equalsIgnoreCase(playerId.username())) { + iter.remove(); + audiences.get(sender).sendMessage( + new TranslatableComponent( + "whitelist.remove", + new PlayerComponent(identities.currentIdentity(playerId), NameStyle.FANCY) + ) + ); + return; + } + } + throw new TranslatableCommandException("whitelist.notFound", username); + } + + @Command( + aliases = {"all"}, + desc = "Whitelist all players currently on the server", + min = 0, + max = 0 + ) + public void all(CommandContext args, final CommandSender sender) throws CommandException { + audiences.get(sender).sendMessage(new TranslatableComponent( + "whitelist.addMulti", + new Component(whitelist.addAllOnline(), ChatColor.AQUA) + )); + } + + + @Command( + aliases = {"kick"}, + desc = "Kick any players not currently on the whitelist", + min = 0, + max = 0 + ) + public void kick(CommandContext args, final CommandSender sender) throws CommandException { + audiences.get(sender).sendMessage(new TranslatableComponent( + "whitelist.kickMulti", + new Component(whitelist.kickAll(), ChatColor.AQUA) + )); + } +} diff --git a/Commons/bukkit/src/main/java/tc/oc/parse/DocumentWatcher.java b/Commons/bukkit/src/main/java/tc/oc/parse/DocumentWatcher.java new file mode 100644 index 0000000..bf4c692 --- /dev/null +++ b/Commons/bukkit/src/main/java/tc/oc/parse/DocumentWatcher.java @@ -0,0 +1,74 @@ +package tc.oc.parse; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.Optional; +import java.util.function.Consumer; +import java.util.logging.Level; +import javax.inject.Inject; +import javax.xml.parsers.DocumentBuilder; + +import com.google.inject.assistedinject.Assisted; +import org.xml.sax.SAXException; +import tc.oc.commons.bukkit.logging.MapdevLogger; +import tc.oc.minecraft.scheduler.MainThreadExecutor; +import tc.oc.file.PathWatcher; +import tc.oc.file.PathWatcherHandle; +import tc.oc.file.PathWatcherService; +import tc.oc.parse.xml.DocumentParser; + +/** + * Watches a single file that is parseable with a bound {@link DocumentParser}. + * + * When the file changes, it is parsed and passed to the given {@link Consumer}. + * If the file is removed, the consumer is called with an empty value. + */ +public class DocumentWatcher implements PathWatcher { + + public interface Factory { + DocumentWatcher create(Path path, Consumer> callback); + } + + private final DocumentBuilder builder; + private final DocumentParser parser; + private final MapdevLogger mapdevLogger; + private final Consumer> callback; + private final PathWatcherHandle handle; + + @Inject DocumentWatcher(@Assisted Path path, @Assisted Consumer> callback, DocumentBuilder builder, DocumentParser parser, MapdevLogger mapdevLogger, PathWatcherService watcherService, MainThreadExecutor executor) throws IOException { + this.builder = builder; + this.parser = parser; + this.mapdevLogger = mapdevLogger; + this.callback = callback; + this.handle = watcherService.watch(path, executor, this); + } + + public void cancel() { + if(handle != null) { + handle.cancel(); + callback.accept(Optional.empty()); + } + } + + @Override + public void fileCreated(Path path) { + fileModified(path); + } + + @Override + public void fileModified(Path path) { + final T document; + try { + document = parser.parse(builder.parse(path.toFile())); + } catch(SAXException | IOException | ParseException e) { + mapdevLogger.log(Level.SEVERE, "Failed to load " + path, e); + return; + } + callback.accept(Optional.of(document)); + } + + @Override + public void fileDeleted(Path path) { + callback.accept(Optional.empty()); + } +} diff --git a/Commons/bukkit/src/main/resources/config.yml b/Commons/bukkit/src/main/resources/config.yml new file mode 100644 index 0000000..96d4afb --- /dev/null +++ b/Commons/bukkit/src/main/resources/config.yml @@ -0,0 +1,35 @@ +# Automatic restart +restart: + interval: 1m # Polling interval + # uptime: 12h # Queue a restart automatically after this much uptime + # memory: 2560 # Queue a restart if heap gets this big + # defer-timeout: 9h # Maximum time restart can be delayed after request + # kick-limit: 5 # Maximum number of players that can be disconnected to restart + +# Nicknames +nicks: + enabled: true + overhead-flair: false + +# AFK auto-kicker +afk: + # timeout: 1h + # warning: 59m30s + # interval: 10s + +join-messages: + enabled: true + cross-network: true + # realms: [] + # families: [] + +reports: + enabled: true + cooldown: 0s + families: [] + cross-server: true + +datadog: + enabled: false + host: localhost + port: 8125 diff --git a/Commons/bukkit/src/main/resources/plugin.yml b/Commons/bukkit/src/main/resources/plugin.yml new file mode 100644 index 0000000..19dfed6 --- /dev/null +++ b/Commons/bukkit/src/main/resources/plugin.yml @@ -0,0 +1,52 @@ +name: ${plugin.prefix} +version: ${project.version}-${git.commit.id.abbrev} +description: ${description} +author: Overcast Network +website: ${url} +main: ${plugin.mainClass} +prefix: ${plugin.prefix} +isolate: true +depend: [API, BukkitSettings, Channels] + +permissions: + nick.see-through-all: + description: See the real names of all nicked players + default: op + skin.change: + description: Change skins + default: false + tablist.edit: + description: Use the tab list commands + default: false + chat.admin: + description: Allows sending and receiving of admin chat messages. + default: false + children: + chat.admin.send: + description: Allows sending of admin chat messages + chat.admin.receive: + description: Allows receiving of admin chat messages + afk.forever: + description: Do not kick for inactivity + whitelist.edit: + description: Use whitelist commands + whitelist.bypass: + description: Bypass the whitelist when connecting to the server + server.visibility: + description: Change the public visibility of the local server + default: false + server.cross-datacenter: + description: Connect directly to servers in other datacenters + default: false + ocn.developer: + description: Various developer commands + default: false + ocn.console: + description: Parent for perms that are always granted to console + default: false + children: + - chat.admin + - skin.change + - tablist.edit + - server.visibility + - ocn.developer diff --git a/Commons/bukkit/src/test/java/tc/oc/commons/CommonsBukkitTest.java b/Commons/bukkit/src/test/java/tc/oc/commons/CommonsBukkitTest.java new file mode 100644 index 0000000..4f00243 --- /dev/null +++ b/Commons/bukkit/src/test/java/tc/oc/commons/CommonsBukkitTest.java @@ -0,0 +1,12 @@ +package tc.oc.commons; + +import com.google.inject.Guice; +import org.junit.Before; +import tc.oc.commons.core.inject.TestModule; + +public abstract class CommonsBukkitTest { + @Before + public void setUp() { + Guice.createInjector(new TestModule()).injectMembers(this); + } +} diff --git a/Commons/bukkit/src/test/java/tc/oc/commons/bukkit/geometry/CapsuleTest.java b/Commons/bukkit/src/test/java/tc/oc/commons/bukkit/geometry/CapsuleTest.java new file mode 100644 index 0000000..1352b5b --- /dev/null +++ b/Commons/bukkit/src/test/java/tc/oc/commons/bukkit/geometry/CapsuleTest.java @@ -0,0 +1,37 @@ +package tc.oc.commons.bukkit.geometry; + +import org.bukkit.util.ImVector; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertFalse; + +@RunWith(JUnit4.class) +public class CapsuleTest { + + @Test + public void testIntersectsPoint() throws Exception { + final Capsule C = Capsule.fromCenterAndRadius(LineSegment.of(0, 0, 0, 1, 1, 1), 0.5); + assertTrue(C.intersects(ImVector.of(0, 0, 0))); + assertTrue(C.intersects(ImVector.of(1, 1, 1))); + assertTrue(C.intersects(ImVector.of(0.5, 0.5, 0.5))); + assertTrue(C.intersects(ImVector.of(0.5, 0, 0))); + assertFalse(C.intersects(ImVector.of(0.9, 0, 0))); + } + + @Test + public void testIntersectsSphere() throws Exception { + final Capsule C = Capsule.fromCenterAndRadius(LineSegment.of(0, 0, 0, 1, 1, 1), 0.5); + assertTrue(C.intersects(Sphere.fromCenterAndRadius(ImVector.of(1, 0, 0), 0.5))); + assertFalse(C.intersects(Sphere.fromCenterAndRadius(ImVector.of(2, 0, 0), 0.5))); + assertTrue(C.intersects(Sphere.fromCenterAndRadius(ImVector.of(2, 1, 2), 1))); + assertTrue(C.intersects(Sphere.fromCenterAndRadius(ImVector.of(2, 2, 2), 1.3))); + + final Capsule LONG = Capsule.fromCenterAndRadius(LineSegment.of(0, 0, 0, 10, 10, 10), 1); + assertTrue(LONG.intersects(Sphere.fromCenterAndRadius(ImVector.of(5, 5, 5), 1))); + assertTrue(LONG.intersects(Sphere.fromCenterAndRadius(ImVector.of(6, 5, 5), 1))); + assertFalse(LONG.intersects(Sphere.fromCenterAndRadius(ImVector.of(8, 5, 5), 1))); + } +} diff --git a/Commons/bukkit/src/test/java/tc/oc/commons/bukkit/geometry/LineSegmentTest.java b/Commons/bukkit/src/test/java/tc/oc/commons/bukkit/geometry/LineSegmentTest.java new file mode 100644 index 0000000..78c7210 --- /dev/null +++ b/Commons/bukkit/src/test/java/tc/oc/commons/bukkit/geometry/LineSegmentTest.java @@ -0,0 +1,56 @@ +package tc.oc.commons.bukkit.geometry; + +import org.bukkit.util.ImVector; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +@RunWith(JUnit4.class) +public class LineSegmentTest { + + private static final double SMIDGE = 0.00000001; + + private static final ImVector + A = ImVector.of(1, 2, 3), + B = ImVector.of(4, 5, 6); + + private static final LineSegment L = LineSegment.between(A, B); + + private static void assertRoughly(double expected, double actual) { + assertEquals(expected, actual, SMIDGE); + } + + @Test + public void testParametericPoint() throws Exception { + assertTrue(L.delta().dot(L.parametricPoint(-1)) < 0); + assertEquals(A, L.parametricPoint(0)); + assertEquals(A.plus(B).times(0.5), L.parametricPoint(0.5)); + assertEquals(B, L.parametricPoint(1)); + assertTrue(L.delta().dot(L.parametricPoint(2)) > 0); + } + + @Test + public void testPerpendicularProjection() throws Exception { + final LineSegment X = LineSegment.of(0, 0, 0, 1, 0, 0); + assertRoughly(-1, X.perpendicularProjectionParameter(ImVector.of(-1, 0, 0))); + assertRoughly(0, X.perpendicularProjectionParameter(ImVector.of(0, 0, 0))); + assertRoughly(1, X.perpendicularProjectionParameter(ImVector.of(1, 0, 0))); + assertRoughly(2, X.perpendicularProjectionParameter(ImVector.of(2, 0, 0))); + + final LineSegment DIAG = LineSegment.of(0, 0, 0, 1, 1, 1); + assertRoughly(1D / 3D, DIAG.perpendicularProjectionParameter(ImVector.of(1, 0, 0))); + assertRoughly(2D / 3D, DIAG.perpendicularProjectionParameter(ImVector.of(1, 1, 0))); + } + + @Test + public void testDistanceFromPoint() throws Exception { + final LineSegment DIAG = LineSegment.of(0, 0, 0, 1, 0, 1); + assertRoughly(Math.sqrt(2) / 2, DIAG.distance(ImVector.of(1, 0, 0))); + assertRoughly(Math.sqrt(2), DIAG.distance(ImVector.of(2, 0, 0))); + assertRoughly(Math.sqrt(5), DIAG.distance(ImVector.of(3, 0, 0))); + assertRoughly(1, DIAG.distance(ImVector.of(-1, 0, 0))); + } +} diff --git a/Commons/bukkit/src/test/java/tc/oc/commons/bukkit/localization/LocalesTest.java b/Commons/bukkit/src/test/java/tc/oc/commons/bukkit/localization/LocalesTest.java new file mode 100644 index 0000000..c849bb7 --- /dev/null +++ b/Commons/bukkit/src/test/java/tc/oc/commons/bukkit/localization/LocalesTest.java @@ -0,0 +1,51 @@ +package tc.oc.commons.bukkit.localization; + +import java.util.Locale; +import java.util.Set; + +import com.google.common.collect.ImmutableSet; +import org.junit.Test; +import tc.oc.commons.core.localization.Locales; + +import static tc.oc.test.Assert.*; +import static org.junit.Assert.*; + +public class LocalesTest { + + @Test + public void matcher() throws Exception { + Set avail = ImmutableSet.of(new Locale("en", "US"), + new Locale("en", "CA"), + new Locale("en"), + new Locale("fr")); + assertSequence( + Locales.match(new Locale("en"), avail), + new Locale("en"), new Locale("en", "US"), new Locale("en", "CA") + ); + + assertSequence( + Locales.match(new Locale("en", "CA"), avail), + new Locale("en", "CA"), new Locale("en"), new Locale("en", "US") + ); + + assertSequence( + Locales.match(new Locale("en", "US"), avail), + new Locale("en", "US"), new Locale("en"), new Locale("en", "CA") + ); + + assertSequence( + Locales.match(new Locale("en", "GB"), avail), + new Locale("en"), new Locale("en", "US"), new Locale("en", "CA") + ); + + assertSequence( + Locales.match(new Locale("fr"), avail), + new Locale("fr"), Locales.DEFAULT_LOCALE + ); + + assertSequence( + Locales.match(new Locale("es"), avail), + Locales.DEFAULT_LOCALE + ); + } +} diff --git a/Commons/bukkit/src/test/java/tc/oc/commons/bukkit/localization/TranslationKeyTests.java b/Commons/bukkit/src/test/java/tc/oc/commons/bukkit/localization/TranslationKeyTests.java new file mode 100644 index 0000000..d52b850 --- /dev/null +++ b/Commons/bukkit/src/test/java/tc/oc/commons/bukkit/localization/TranslationKeyTests.java @@ -0,0 +1,26 @@ +package tc.oc.commons.bukkit.localization; + +import javax.inject.Inject; + +import org.junit.Test; +import tc.oc.api.docs.virtual.MapDoc; +import tc.oc.commons.CommonsBukkitTest; + +import static org.junit.Assert.assertTrue; + +public class TranslationKeyTests extends CommonsBukkitTest { + + @Inject Translations translations; + + @Test + public void testAllGamemodesHaveLocalizedNames() throws Exception { + for(MapDoc.Gamemode gamemode : MapDoc.Gamemode.values()) { + assertTrue("Gamemode." + gamemode + " has no short name", + translations.hasKey(Translations.gamemodeShortName(gamemode))); + + assertTrue("Gamemode." + gamemode + " has no long name", + translations.hasKey(Translations.gamemodeLongName(gamemode))); + } + } + +} diff --git a/Commons/bukkit/src/test/java/tc/oc/commons/bukkit/util/PotionClassificationTest.java b/Commons/bukkit/src/test/java/tc/oc/commons/bukkit/util/PotionClassificationTest.java new file mode 100644 index 0000000..b839ab6 --- /dev/null +++ b/Commons/bukkit/src/test/java/tc/oc/commons/bukkit/util/PotionClassificationTest.java @@ -0,0 +1,125 @@ +package tc.oc.commons.bukkit.util; + +import java.util.List; + +import com.google.common.collect.ImmutableList; +import org.bukkit.Bukkit; +import org.bukkit.CraftBukkitRuntime; +import org.bukkit.Material; +import org.bukkit.craftbukkit.potion.CraftPotionBrewer; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.PotionMeta; +import org.bukkit.potion.Potion; +import org.bukkit.potion.PotionData; +import org.bukkit.potion.PotionEffect; +import org.bukkit.potion.PotionType; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +import static org.bukkit.potion.PotionEffectType.*; +import static org.junit.Assert.*; +import static tc.oc.commons.bukkit.util.PotionClassification.*; + +/** Tests for {@link PotionUtils} and {@link PotionClassification} */ +@RunWith(JUnit4.class) +public class PotionClassificationTest { + + @Before + public void setUp() { + CraftBukkitRuntime.load(); + if(Potion.getBrewer() == null) { + Potion.setPotionBrewer(new CraftPotionBrewer()); + } + } + + @Test + public void effectTypes() throws Exception { + assertEquals(BENEFICIAL, classify(HEAL)); + assertEquals(HARMFUL, classify(HARM)); + } + + @Test + public void classifyByMostEffects() throws Exception { + assertEquals(BENEFICIAL, classify(ImmutableList.of( + new PotionEffect(SPEED, 1, 0), + new PotionEffect(HARM, 1, 0), + new PotionEffect(LUCK, 1, 0) + ))); + + assertEquals(HARMFUL, classify(ImmutableList.of( + new PotionEffect(SLOW, 1, 0), + new PotionEffect(HEAL, 1, 0), + new PotionEffect(UNLUCK, 1, 0) + ))); + } + + @Test + public void classifyByDuration() throws Exception { + assertEquals(BENEFICIAL, classify(ImmutableList.of( + new PotionEffect(HEAL, 2, 0), + new PotionEffect(HARM, 1, 0) + ))); + + assertEquals(HARMFUL, classify(ImmutableList.of( + new PotionEffect(HEAL, 1, 0), + new PotionEffect(HARM, 2, 0) + ))); + } + + @Test + public void classifyByAmplifier() throws Exception { + assertEquals(BENEFICIAL, classify(ImmutableList.of( + new PotionEffect(HEAL, 1, 1), + new PotionEffect(HARM, 1, 0) + ))); + + assertEquals(HARMFUL, classify(ImmutableList.of( + new PotionEffect(HEAL, 1, 0), + new PotionEffect(HARM, 1, 1) + ))); + } + + @Test + public void negativeAmplifier() throws Exception { + assertEquals(BENEFICIAL, classify(ImmutableList.of( + new PotionEffect(HARM, 1, -1) + ))); + assertEquals(HARMFUL, classify(ImmutableList.of( + new PotionEffect(HEAL, 1, -1) + ))); + } + + @Test + public void vanillaBrews() throws Exception { + assertEquals(BENEFICIAL, classify(Bukkit.potionRegistry().get(Bukkit.key("healing")))); + assertEquals(BENEFICIAL, classify(new PotionData(PotionType.INSTANT_HEAL, false, false))); + assertEquals(HARMFUL, classify(Bukkit.potionRegistry().get(Bukkit.key("harming")))); + assertEquals(HARMFUL, classify(new PotionData(PotionType.INSTANT_DAMAGE, false, false))); + } + + @Test + public void potionItem() throws Exception { + final ItemStack item = new ItemStack(Material.POTION); + final PotionMeta meta = (PotionMeta) item.getItemMeta(); + meta.setPotionBrew(Bukkit.potionRegistry().get(Bukkit.key("healing"))); + item.setItemMeta(meta); + + } + + @Test + public void riftCaseTest() { + List effects = ImmutableList.of( + new PotionEffect(FAST_DIGGING, 3600, 3), + new PotionEffect(REGENERATION, 3600, 2), + new PotionEffect(DAMAGE_RESISTANCE, 3600, 1), + new PotionEffect(FIRE_RESISTANCE, 3600, 1), + new PotionEffect(SPEED, 3600, 1), + new PotionEffect(INCREASE_DAMAGE, 3600, 1) + ); + + assertEquals("Rift Baron potion was not classified as ", + PotionClassification.BENEFICIAL, classify(effects)); + } +} diff --git a/Commons/bungee/pom.xml b/Commons/bungee/pom.xml new file mode 100644 index 0000000..86e4dbf --- /dev/null +++ b/Commons/bungee/pom.xml @@ -0,0 +1,73 @@ + + + 4.0.0 + + + commons + tc.oc + ../pom.xml + 1.11-SNAPSHOT + + + commons-bungee + + + + Commons + + tc.oc.commons.bungee.CommonsBungeeManifest + + + + + tc.oc + commons-core + ${project.version} + + + + tc.oc + api-bungee + ${project.version} + + + + + + + org.apache.maven.plugins + maven-shade-plugin + 2.1 + + + package + + shade + + + + + tc.oc:commons-core + + + + + + + + pl.project13.maven + git-commit-id-plugin + 2.1.0 + + + + revision + + + + + + + diff --git a/Commons/bungee/src/main/java/tc/oc/bungee/analytics/BungeePlayerReporter.java b/Commons/bungee/src/main/java/tc/oc/bungee/analytics/BungeePlayerReporter.java new file mode 100644 index 0000000..3fc22ca --- /dev/null +++ b/Commons/bungee/src/main/java/tc/oc/bungee/analytics/BungeePlayerReporter.java @@ -0,0 +1,21 @@ +package tc.oc.bungee.analytics; + +import net.md_5.bungee.api.event.PlayerDisconnectEvent; +import net.md_5.bungee.api.event.PostLoginEvent; +import net.md_5.bungee.api.plugin.Listener; +import net.md_5.bungee.event.EventHandler; +import net.md_5.bungee.event.EventPriority; +import tc.oc.minecraft.analytics.PlayerReporter; + +public class BungeePlayerReporter extends PlayerReporter implements Listener { + + @EventHandler(priority = EventPriority.HIGHEST) + public void join(PostLoginEvent event) { + join(); + } + + @EventHandler(priority = EventPriority.HIGHEST) + public void leave(PlayerDisconnectEvent event) { + leave(); + } +} diff --git a/Commons/bungee/src/main/java/tc/oc/bungee/analytics/PlayerTimeoutReporter.java b/Commons/bungee/src/main/java/tc/oc/bungee/analytics/PlayerTimeoutReporter.java new file mode 100644 index 0000000..ac3748b --- /dev/null +++ b/Commons/bungee/src/main/java/tc/oc/bungee/analytics/PlayerTimeoutReporter.java @@ -0,0 +1,47 @@ +package tc.oc.bungee.analytics; + +import javax.inject.Inject; + +import com.google.common.collect.ImmutableSet; +import net.md_5.bungee.api.event.PlayerDisconnectEvent; +import net.md_5.bungee.api.plugin.Listener; +import net.md_5.bungee.event.EventHandler; +import tc.oc.analytics.AnalyticsClient; +import tc.oc.analytics.Count; +import tc.oc.analytics.DynamicTagger; +import tc.oc.analytics.Event; +import tc.oc.analytics.MetricFactory; +import tc.oc.analytics.Tag; +import tc.oc.minecraft.analytics.AnalyticsFacet; + +public class PlayerTimeoutReporter extends AnalyticsFacet implements Listener { + + private final Event event = Event.error("player.timeout", "Player timeout"); + + private final AnalyticsClient client; + private final DynamicTagger tagger; + private final Count timeouts; + + @Inject PlayerTimeoutReporter(AnalyticsClient client, MetricFactory metrics, DynamicTagger tagger) { + this.client = client; + this.timeouts = metrics.count("players.timeouts"); + this.tagger = tagger; + } + + @EventHandler + public void onDisconnect(PlayerDisconnectEvent event) { + final Throwable exception = event.getPlayer().getDisconnectException(); + if(exception != null && "ReadTimeoutException".equals(exception.getClass().getSimpleName())) { + tagger.withTags( + ImmutableSet.of( + Tag.of("uuid", event.getPlayer().getUniqueId().toString()), + Tag.of("username", event.getPlayer().getName()) + ), + () -> { + client.event(this.event.withBody(event.getPlayer().getName() + " timed out")); + timeouts.increment(); + } + ); + } + } +} diff --git a/Commons/bungee/src/main/java/tc/oc/commons/bungee/CommonsBungeeManifest.java b/Commons/bungee/src/main/java/tc/oc/commons/bungee/CommonsBungeeManifest.java new file mode 100644 index 0000000..63cc1a2 --- /dev/null +++ b/Commons/bungee/src/main/java/tc/oc/commons/bungee/CommonsBungeeManifest.java @@ -0,0 +1,52 @@ +package tc.oc.commons.bungee; + +import tc.oc.api.model.ModelListenerBinder; +import tc.oc.commons.bungee.commands.ServerCommands; +import tc.oc.commons.bungee.inject.BungeePluginManifest; +import tc.oc.commons.bungee.listeners.LoginListener; +import tc.oc.commons.bungee.listeners.MetricListener; +import tc.oc.commons.bungee.listeners.PingListener; +import tc.oc.bungee.analytics.BungeePlayerReporter; +import tc.oc.bungee.analytics.PlayerTimeoutReporter; +import tc.oc.commons.bungee.listeners.PlayerServerRouter; +import tc.oc.commons.bungee.listeners.TeleportListener; +import tc.oc.commons.bungee.restart.RestartListener; +import tc.oc.commons.bungee.servers.LobbyTracker; +import tc.oc.commons.bungee.servers.ServerTracker; +import tc.oc.commons.bungee.sessions.MojangSessionServiceCommands; +import tc.oc.commons.bungee.sessions.MojangSessionServiceMonitor; +import tc.oc.commons.core.CommonsCoreManifest; +import tc.oc.commons.core.inject.HybridManifest; +import tc.oc.commons.core.plugin.PluginFacetBinder; + +public class CommonsBungeeManifest extends HybridManifest { + @Override + protected void configure() { + install(new CommonsCoreManifest()); + install(new BungeePluginManifest()); + + bindAndExpose(ServerTracker.class); + bindAndExpose(LobbyTracker.class); + + final ModelListenerBinder models = new ModelListenerBinder(publicBinder()); + models.bindListener().to(ServerTracker.class); + models.bindListener().to(LobbyTracker.class); + + final PluginFacetBinder facets = new PluginFacetBinder(binder()); + facets.register(ServerTracker.class); + facets.register(LobbyTracker.class); + facets.register(LoginListener.class); + facets.register(MetricListener.class); + facets.register(MojangSessionServiceCommands.class); + facets.register(MojangSessionServiceMonitor.class); + facets.register(PingListener.class); + facets.register(RestartListener.class); + facets.register(ServerCommands.class); + facets.register(PlayerServerRouter.class); + facets.register(TeleportListener.class); + + // DataDog + facets.register(BungeePlayerReporter.class); + facets.register(PlayerTimeoutReporter.class); + } +} diff --git a/Commons/bungee/src/main/java/tc/oc/commons/bungee/commands/ServerCommands.java b/Commons/bungee/src/main/java/tc/oc/commons/bungee/commands/ServerCommands.java new file mode 100644 index 0000000..377a445 --- /dev/null +++ b/Commons/bungee/src/main/java/tc/oc/commons/bungee/commands/ServerCommands.java @@ -0,0 +1,104 @@ +package tc.oc.commons.bungee.commands; + +import java.util.concurrent.ExecutorService; +import javax.inject.Inject; + +import com.google.common.util.concurrent.Futures; +import com.sk89q.minecraft.util.commands.Command; +import com.sk89q.minecraft.util.commands.CommandContext; +import com.sk89q.minecraft.util.commands.CommandException; +import com.sk89q.minecraft.util.commands.CommandPermissions; +import net.md_5.bungee.api.ChatColor; +import net.md_5.bungee.api.CommandSender; +import net.md_5.bungee.api.ProxyServer; +import net.md_5.bungee.api.chat.ComponentBuilder; +import net.md_5.bungee.api.chat.TextComponent; +import net.md_5.bungee.api.config.ServerInfo; +import net.md_5.bungee.api.connection.ProxiedPlayer; +import net.md_5.bungee.api.plugin.CommandBypassException; +import tc.oc.api.docs.Server; +import tc.oc.api.docs.virtual.ServerDoc; +import tc.oc.api.model.ModelSync; +import tc.oc.commons.bungee.servers.ServerTracker; +import tc.oc.commons.core.commands.Commands; +import tc.oc.commons.core.restart.RestartManager; + +public class ServerCommands implements Commands { + + private final RestartManager restartManager; + private final ServerTracker serverTracker; + private final ProxyServer proxy; + private final ExecutorService executor; + + @Inject ServerCommands(RestartManager restartManager, ServerTracker serverTracker, ProxyServer proxy, @ModelSync ExecutorService executor) { + this.restartManager = restartManager; + this.serverTracker = serverTracker; + this.proxy = proxy; + this.executor = executor; + } + + @Command( + aliases = {"hub", "lobby"}, + desc = "Teleport to the lobby" + ) + public void hub(final CommandContext args, CommandSender sender) throws CommandException { + if(sender instanceof ProxiedPlayer) { + final ProxiedPlayer player = (ProxiedPlayer) sender; + final Server server = Futures.getUnchecked(executor.submit(() -> serverTracker.byPlayer(player))); + if(server.role() == ServerDoc.Role.LOBBY || server.role() == ServerDoc.Role.PGM) { + // If Bukkit server is running Commons, let it handle the command + throw new CommandBypassException(); + } + + player.connect(proxy.getServerInfo("default")); + player.sendMessage(new ComponentBuilder("Teleporting you to the lobby").color(ChatColor.GREEN).create()); + } else { + sender.sendMessage(new ComponentBuilder("Only players may use this command").color(ChatColor.RED).create()); + } + } + + @Command( + aliases = {"gserver"}, + desc = "Global server teleport", + usage = "", + min = 1, + max = 1 + ) + @CommandPermissions("bungeecord.command.server") + public void gserver(final CommandContext args, CommandSender sender) { + if(sender instanceof ProxiedPlayer) { + String name = args.getString(0); + ServerInfo info = proxy.getServerInfo(name); + if(info != null) { + ((ProxiedPlayer) sender).connect(info); + sender.sendMessage(new ComponentBuilder("Teleporting you to: ").color(ChatColor.GREEN).append(name).color(ChatColor.GOLD).create()); + } else { + sender.sendMessage(new ComponentBuilder("Invalid server: ").color(ChatColor.RED).append(name).color(ChatColor.GOLD).create()); + } + } else { + sender.sendMessage(new ComponentBuilder("Only players may use this command").color(ChatColor.RED).create()); + } + } + + @Command( + aliases = {"gqueuerestart", "gqr"}, + usage = "[-c]", + desc = "Shutdown the next time the server is inactive and empty", + flags = "c", + help = "The -c flag cancels a previously queued restart" + ) + @CommandPermissions("bungeecord.command.restart") + public void queueRestart(final CommandContext args, final CommandSender sender) throws CommandException { + if(restartManager == null) { + throw new CommandException("Scheduled restarts are not enabled"); + } else { + if(args.hasFlag('c')) { + sender.sendMessage(new TextComponent("Restart cancelled")); + restartManager.cancelRestart(); + } else { + sender.sendMessage(new TextComponent("Restart queued")); + restartManager.requestRestart("/gqr command by " + sender.getName()); + } + } + } +} diff --git a/Commons/bungee/src/main/java/tc/oc/commons/bungee/listeners/LoginListener.java b/Commons/bungee/src/main/java/tc/oc/commons/bungee/listeners/LoginListener.java new file mode 100644 index 0000000..f43a067 --- /dev/null +++ b/Commons/bungee/src/main/java/tc/oc/commons/bungee/listeners/LoginListener.java @@ -0,0 +1,241 @@ +package tc.oc.commons.bungee.listeners; + +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentMap; +import java.util.logging.Logger; +import javax.inject.Inject; +import javax.inject.Singleton; + +import com.google.common.collect.MapMaker; +import com.google.common.util.concurrent.Futures; +import net.md_5.bungee.api.ProxyServer; +import net.md_5.bungee.api.config.ServerInfo; +import net.md_5.bungee.api.connection.PendingConnection; +import net.md_5.bungee.api.connection.ProxiedPlayer; +import net.md_5.bungee.api.event.AsyncEvent; +import net.md_5.bungee.api.event.LoginEvent; +import net.md_5.bungee.api.event.PlayerDisconnectEvent; +import net.md_5.bungee.api.event.PostLoginEvent; +import net.md_5.bungee.api.event.PreLoginEvent; +import net.md_5.bungee.api.event.ServerConnectEvent; +import net.md_5.bungee.api.plugin.Cancellable; +import net.md_5.bungee.api.plugin.Event; +import net.md_5.bungee.api.plugin.Listener; +import net.md_5.bungee.api.plugin.Plugin; +import net.md_5.bungee.event.EventHandler; +import net.md_5.bungee.event.EventPriority; +import tc.oc.api.bungee.users.BungeeUserStore; +import tc.oc.api.docs.PlayerId; +import tc.oc.api.docs.Server; +import tc.oc.api.docs.User; +import tc.oc.api.docs.virtual.ServerDoc; +import tc.oc.api.minecraft.MinecraftService; +import tc.oc.api.users.LoginRequest; +import tc.oc.api.users.LoginResponse; +import tc.oc.api.users.LogoutRequest; +import tc.oc.api.users.UserService; +import tc.oc.api.util.Permissions; +import tc.oc.commons.bungee.sessions.MojangSessionServiceMonitor; +import tc.oc.commons.bungee.sessions.SessionState; +import tc.oc.commons.core.logging.Loggers; +import tc.oc.commons.core.plugin.PluginFacet; +import tc.oc.commons.core.util.SystemFutureCallback; +import tc.oc.minecraft.protocol.MinecraftVersion; + +@Singleton +public class LoginListener implements Listener, PluginFacet { + + private static final String INTERNAL_ERROR = "Internal error\n\nPlease try again later"; + private static final String NOT_ALLOWED = "You are not allowed on this server"; + + private final Logger logger; + private final Plugin plugin; + private final ProxyServer proxy; + private final MinecraftService minecraftService; + private final UserService userService; + private final BungeeUserStore userStore; + private final MojangSessionServiceMonitor mojangSessionServiceMonitor; + private final ConcurrentMap pendingConnections = new MapMaker().weakKeys().makeMap(); + private final ConcurrentMap initialServers = new MapMaker().weakKeys().makeMap(); + + @Inject LoginListener(Loggers loggers, Plugin plugin, ProxyServer proxy, MinecraftService minecraftService, UserService userService, BungeeUserStore userStore, MojangSessionServiceMonitor mojangSessionServiceMonitor) { + this.proxy = proxy; + this.mojangSessionServiceMonitor = mojangSessionServiceMonitor; + this.logger = loggers.get(getClass()); + this.plugin = plugin; + this.minecraftService = minecraftService; + this.userService = userService; + this.userStore = userStore; + } + + private void log(PendingConnection connection, String message) { + logger.info("[" + connection.getAddress().getAddress().getHostAddress() + + " " + connection.getName() + + " " + connection.getUniqueId() + + "] " + message); + } + + @EventHandler + public void preLogin(final PreLoginEvent event) { + if(mojangSessionServiceMonitor.getState() == SessionState.OFFLINE) { + event.getConnection().setOnlineMode(false); + log(event.getConnection(), "Starting offline login"); + doLogin(event, event.getConnection()); + } + } + + @EventHandler + public void login(final LoginEvent event) { + if(!pendingConnections.containsKey(event.getConnection())) { + log(event.getConnection(), "Starting online login"); + doLogin(event, event.getConnection()); + } + } + + @EventHandler + public void postLogin(final PostLoginEvent event) { + final User user = pendingConnections.remove(event.getPlayer().getPendingConnection()); + if(user != null) { + log(event.getPlayer().getPendingConnection(), "Completing login"); + userStore.addUser(event.getPlayer(), user); + applyPermissions(event.getPlayer(), user); + updateServerStatus(proxy.getOnlineCount()); + } else { + logger.severe("No pending connection for " + event.getPlayer().getName() + ":" + event.getPlayer().getUniqueId()); + event.getPlayer().disconnect(INTERNAL_ERROR); + } + } + + private void doLogin(final AsyncEvent event, final PendingConnection connection) { + event.registerIntent(this.plugin); + + final Server localServer = minecraftService.getLocalServer(); + + // null if called from preLogin + final UUID uuid = connection.getUniqueId(); + + String username = null; + if(uuid != null && localServer.fake_usernames() != null) { + username = minecraftService.getLocalServer().fake_usernames().get(uuid); + } + if(username == null) { + username = connection.getName(); + } + + final LoginRequest loginRequest = new LoginRequest(username, + uuid, + connection.getAddress().getAddress(), + minecraftService.getLocalServer(), + connection.getVirtualHost(), + false, + MinecraftVersion.describeProtocol(connection.getVersion())); + + Futures.addCallback(userService.login(loginRequest), new SystemFutureCallback() { + @Override + public void onSuccessThrows(LoginResponse response) { + if(response.kick() != null) { + // NOTE: if login is cancelled, other fields in the response may be null + this.finish(true, response.message()); + return; + } + + final Map permissions = mergePermissions(response.user()); + if(!Boolean.TRUE.equals(permissions.get(Permissions.LOGIN))) { + this.finish(true, NOT_ALLOWED); + return; + } + + // If we are doing an offline login, UUID will be null at this point, + // and we need to set it to the correct one from the user document, + // so that Bungee doesn't set it to a random one later. + if(uuid == null) { + connection.setUniqueId(response.user().uuid()); + } + + pendingConnections.put(connection, response.user()); + + if (response.route_to_server() != null) { + ServerInfo target = proxy.getServerInfo(response.route_to_server()); + if (target == null) { + this.finish(true, "Routing to server failed\n\nPlease try again later"); + return; + } + initialServers.put(connection, target); + } + + this.finish(false, response.message()); + } + + @Override + public void onFailure(Throwable throwable) { + super.onFailure(throwable); + this.finish(true, INTERNAL_ERROR); + } + + private void finish(boolean cancelled, String message) { + if(cancelled) { + log(connection, "Denying login: " + message); + cancelEvent(event, message); + } else { + log(connection, "Allowing login"); + } + event.completeIntent(LoginListener.this.plugin); + } + }); + } + + private Map mergePermissions(User user) { + return Permissions.mergePermissions(minecraftService.getLocalServer().realms(), user.mc_permissions_by_realm()); + } + + private void applyPermissions(ProxiedPlayer player, User user) { + for(Map.Entry entry : mergePermissions(user).entrySet()) { + player.setPermission(entry.getKey(), entry.getValue()); + } + } + + private void cancelEvent(Event event, String reason) { + if(event instanceof Cancellable) { + ((Cancellable) event).setCancelled(true); + } + if((event instanceof LoginEvent)) { + ((LoginEvent) event).setCancelReason(reason); + } else if(event instanceof PreLoginEvent) { + ((PreLoginEvent) event).setCancelReason(reason); + } + } + + @EventHandler(priority = EventPriority.LOW) + public void connect(ServerConnectEvent event) { + ServerInfo target = initialServers.remove(event.getPlayer().getPendingConnection()); + if(target != null) { + log(event.getPlayer().getPendingConnection(), "Routing to initial server " + target.getName()); + event.setTarget(target); + } + } + + @EventHandler + public void disconnect(final PlayerDisconnectEvent event) { + pendingConnections.remove(event.getPlayer().getPendingConnection()); + PlayerId playerId = userStore.removeUser(event.getPlayer()); + if(playerId != null) { + log(event.getPlayer().getPendingConnection(), "Logging out"); + LogoutRequest logoutRequest = new LogoutRequest(playerId, this.minecraftService.getLocalServer()); + this.userService.logout(logoutRequest); + updateServerStatus(proxy.getOnlineCount() - 1); // Adjust for disconnecting player included in count + } + } + + private void updateServerStatus(int count) { + minecraftService.updateLocalServer(new ServerDoc.StatusUpdate() { + @Override public int max_players() { + return proxy.getConfig().getPlayerLimit(); + } + + @Override public int num_observing() { + return count; + } + }); + } +} diff --git a/Commons/bungee/src/main/java/tc/oc/commons/bungee/listeners/MetricListener.java b/Commons/bungee/src/main/java/tc/oc/commons/bungee/listeners/MetricListener.java new file mode 100644 index 0000000..6a3d6c6 --- /dev/null +++ b/Commons/bungee/src/main/java/tc/oc/commons/bungee/listeners/MetricListener.java @@ -0,0 +1,37 @@ +package tc.oc.commons.bungee.listeners; + +import java.net.InetSocketAddress; +import javax.inject.Inject; +import javax.inject.Singleton; + +import net.md_5.bungee.api.event.PostLoginEvent; +import net.md_5.bungee.api.event.ProxyPingEvent; +import net.md_5.bungee.api.plugin.Listener; +import net.md_5.bungee.event.EventHandler; +import tc.oc.api.servers.ServerService; +import tc.oc.api.servers.BungeeMetricRequest; +import tc.oc.commons.core.plugin.PluginFacet; + +@Singleton +public class MetricListener implements Listener, PluginFacet { + + private final ServerService serverService; + + @Inject MetricListener(ServerService serverService) { + this.serverService = serverService; + } + + @EventHandler + public void ping(final ProxyPingEvent event) { + doBungeMetric(event.getConnection().getAddress(), BungeeMetricRequest.Type.PING); + } + + @EventHandler + public void join(final PostLoginEvent event) { + doBungeMetric(event.getPlayer().getAddress(), BungeeMetricRequest.Type.LOGIN); + } + + private void doBungeMetric(InetSocketAddress address, BungeeMetricRequest.Type type) { + serverService.doBungeeMetric(new BungeeMetricRequest(address.getAddress().getHostAddress(), type)); + } +} diff --git a/Commons/bungee/src/main/java/tc/oc/commons/bungee/listeners/PingListener.java b/Commons/bungee/src/main/java/tc/oc/commons/bungee/listeners/PingListener.java new file mode 100644 index 0000000..00352c4 --- /dev/null +++ b/Commons/bungee/src/main/java/tc/oc/commons/bungee/listeners/PingListener.java @@ -0,0 +1,108 @@ +package tc.oc.commons.bungee.listeners; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Random; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.logging.Logger; +import javax.inject.Inject; +import javax.inject.Singleton; + +import net.md_5.bungee.api.ChatColor; +import net.md_5.bungee.api.ServerPing; +import net.md_5.bungee.api.chat.BaseComponent; +import net.md_5.bungee.api.event.ProxyPingEvent; +import net.md_5.bungee.api.plugin.Listener; +import net.md_5.bungee.api.plugin.Plugin; +import net.md_5.bungee.event.EventHandler; +import tc.oc.api.docs.Server; +import tc.oc.api.docs.virtual.ServerDoc.Banner; +import tc.oc.api.model.ModelSync; +import tc.oc.api.servers.ServerStore; +import tc.oc.commons.bungee.servers.LobbyTracker; +import tc.oc.commons.core.chat.Component; +import tc.oc.commons.core.logging.Loggers; +import tc.oc.commons.core.plugin.PluginFacet; +import tc.oc.minecraft.protocol.MinecraftVersion; + +import static tc.oc.commons.core.stream.Collectors.toImmutableSet; + +@Singleton +public class PingListener implements Listener, PluginFacet { + private static final int MAX_PLAYERS = 3000; + + private final Logger logger; + private final Random random = new Random(); + private final Plugin plugin; + private final Server localServer; + private final ServerStore serverStore; + private final ExecutorService executor; + private final LobbyTracker lobbyTracker; + + @Inject PingListener(Loggers loggers, Plugin plugin, Server localServer, ServerStore serverStore, LobbyTracker lobbyTracker, @ModelSync ExecutorService executor) { + this.plugin = plugin; + this.localServer = localServer; + this.serverStore = serverStore; + this.executor = executor; + this.lobbyTracker = lobbyTracker; + this.logger = loggers.get(getClass()); + } + + private Banner chooseBanner() { + List banners = localServer.banners(); + if(banners.isEmpty()) return null; + + int totalWeight = 0; + for(Banner banner : banners) totalWeight += banner.weight(); + int rando = random.nextInt(totalWeight); + + for(Banner banner : banners) { + rando -= banner.weight(); + if(rando < 0) return banner; + } + + return null; + } + + + @EventHandler + public void onPing(final ProxyPingEvent event) { + event.registerIntent(plugin); + + final int proto = event.getConnection().getVersion(); + final Set supported = lobbyTracker.supportedProtocols().collect(toImmutableSet()); + + if(!supported.isEmpty() && !supported.contains(proto)) { + event.getResponse().setVersion(new ServerPing.Protocol( + new Component("Connect with ", ChatColor.RED) + .extra(describeVersionRange(supported)) + .toLegacyText(), + -1 + )); + } + + executor.execute(() -> { + event.getResponse().setPlayers(new ServerPing.Players(MAX_PLAYERS, serverStore.countBukkitPlayers(), null)); + final Banner banner = chooseBanner(); + if(banner != null) event.getResponse().setDescription(banner.rendered()); + event.completeIntent(plugin); + }); + } + + public static BaseComponent describeVersionRange(Collection protos) { + final Set versions = protos.stream() + .map(MinecraftVersion::byProtocol) + .filter(v -> v != null) + .collect(toImmutableSet()); + final MinecraftVersion oldest = Collections.min(versions); + final MinecraftVersion newest = Collections.max(versions); + final Component c = new Component(oldest.version(), ChatColor.AQUA); + if(oldest != newest) { + c.extra(" to ", ChatColor.RED) + .extra(newest.version()); + } + return c; + } +} diff --git a/Commons/bungee/src/main/java/tc/oc/commons/bungee/listeners/PlayerServerRouter.java b/Commons/bungee/src/main/java/tc/oc/commons/bungee/listeners/PlayerServerRouter.java new file mode 100644 index 0000000..200d9d9 --- /dev/null +++ b/Commons/bungee/src/main/java/tc/oc/commons/bungee/listeners/PlayerServerRouter.java @@ -0,0 +1,98 @@ +package tc.oc.commons.bungee.listeners; + +import java.util.Optional; +import java.util.Set; +import java.util.logging.Logger; +import javax.inject.Inject; +import javax.inject.Singleton; + +import net.md_5.bungee.api.ChatColor; +import net.md_5.bungee.api.config.ServerInfo; +import net.md_5.bungee.api.event.ServerConnectEvent; +import net.md_5.bungee.api.event.ServerKickEvent; +import net.md_5.bungee.api.plugin.Listener; +import net.md_5.bungee.event.EventHandler; +import tc.oc.api.docs.Server; +import tc.oc.commons.bungee.servers.LobbyTracker; +import tc.oc.commons.core.chat.Component; +import tc.oc.commons.core.logging.Loggers; +import tc.oc.commons.core.plugin.PluginFacet; + +import static tc.oc.commons.core.stream.Collectors.toImmutableSet; + +/** + * Routes players to lobbies, and handles a few other things + * related to switching servers. + */ +@Singleton +public class PlayerServerRouter implements Listener, PluginFacet { + private static final String KICK_COLOR_CODES = ChatColor.BLACK.toString() + ChatColor.RED.toString(); + + private final Logger logger; + private final Server localServer; + private final LobbyTracker lobbyTracker; + + @Inject PlayerServerRouter(Loggers loggers, Server localServer, LobbyTracker lobbyTracker) { + this.logger = loggers.get(getClass()); + this.localServer = localServer; + this.lobbyTracker = lobbyTracker; + } + + @EventHandler + public void connect(final ServerConnectEvent event) { + // don't send "could not connect to server you're already on" message + if(event.getPlayer().getServer() != null && event.getPlayer().getServer().getInfo().equals(event.getTarget())) { + event.setCancelled(true); + } + + if(event.getTarget().getName().equals("default")) { + final int proto = event.getPlayer().getProtocolVersion(); + + final Optional lobby = lobbyTracker.chooseLobby(proto); + if(lobby.isPresent()) { + event.setTarget(lobby.get()); + } else { + final Set supported = lobbyTracker.supportedProtocols().collect(toImmutableSet()); + if(!supported.isEmpty()) { + event.getPlayer().disconnect( + new Component("Please connect with Minecraft ", ChatColor.RED) + .extra(PingListener.describeVersionRange(supported)) + ); + } + } + } + + event.setFakeUsername(localServer.fake_usernames().get(event.getPlayer().getUniqueId())); + } + + @EventHandler + public void reroute(final ServerKickEvent event) { + if(event.getKickReason().contains(KICK_COLOR_CODES)) { + event.getPlayer().disconnect(event.getKickReason()); + } else { + event.setCancelled(true); + + if(event.getState() == ServerKickEvent.State.CONNECTED) { + // Player was kicked off a server they were already connected to + // Send them to the lobby, and make sure it's not the server they just left + lobbyTracker.chooseLobby(event.getPlayer().getProtocolVersion(), + event.getKickedFrom()) + .ifPresent(event::setCancelServer); + + } else if(event.getState() == ServerKickEvent.State.CONNECTING) { + // Player was kicked when trying to connect to a server + if(event.getPlayer().getServer() != null) { + // If they are currently on a different server, keep them there + event.setCancelServer(event.getPlayer().getServer().getInfo()); + for(String message : event.getKickReason().split("\n\n")) { + event.getPlayer().sendMessage(message); + } + + } else { + // Otherwise they were trying to connect to the network, so don't let them + event.getPlayer().disconnect(event.getKickReason()); + } + } + } + } +} diff --git a/Commons/bungee/src/main/java/tc/oc/commons/bungee/listeners/TeleportListener.java b/Commons/bungee/src/main/java/tc/oc/commons/bungee/listeners/TeleportListener.java new file mode 100644 index 0000000..b1ed388 --- /dev/null +++ b/Commons/bungee/src/main/java/tc/oc/commons/bungee/listeners/TeleportListener.java @@ -0,0 +1,68 @@ +package tc.oc.commons.bungee.listeners; + +import java.util.Objects; +import java.util.concurrent.ExecutorService; +import java.util.logging.Logger; +import javax.inject.Inject; + +import net.md_5.bungee.api.ProxyServer; +import net.md_5.bungee.api.connection.ProxiedPlayer; +import tc.oc.api.docs.Server; +import tc.oc.api.docs.virtual.ServerDoc; +import tc.oc.api.message.MessageListener; +import tc.oc.api.message.MessageQueue; +import tc.oc.api.message.types.PlayerTeleportRequest; +import tc.oc.api.model.ModelSync; +import tc.oc.commons.bungee.servers.ServerTracker; +import tc.oc.commons.core.logging.Loggers; +import tc.oc.commons.core.plugin.PluginFacet; + +/** + * Handles remote teleport requests for players on servers that are not running Commons + */ +public class TeleportListener implements MessageListener, PluginFacet { + + private final ProxyServer proxy; + private final Logger logger; + private final ServerTracker serverTracker; + private final MessageQueue primaryQueue; + private final ExecutorService executor; + + @Inject TeleportListener(Loggers loggers, ProxyServer proxy, ServerTracker serverTracker, MessageQueue primaryQueue, @ModelSync ExecutorService executor) { + this.proxy = proxy; + this.executor = executor; + this.logger = loggers.get(getClass()); + this.serverTracker = serverTracker; + this.primaryQueue = primaryQueue; + } + + @Override + public void enable() { + primaryQueue.subscribe(this, executor); + primaryQueue.bind(PlayerTeleportRequest.class); + } + + @Override + public void disable() { + primaryQueue.unsubscribe(this); + } + + @HandleMessage + public void onTeleport(PlayerTeleportRequest message) { + final ProxiedPlayer player = proxy.getPlayer(message.player_uuid); + if(player == null) return; + + serverTracker.serverInfo(message.target_server()).ifPresent(targetServerInfo -> { + final Server server = serverTracker.byPlayer(player); + if(server.role() == ServerDoc.Role.LOBBY || server.role() == ServerDoc.Role.PGM) { + // If Bukkit server is running Commons, let it handle the teleport + return; + } + + if(!Objects.equals(player.getServer().getInfo(), targetServerInfo)) { + logger.info("Remote teleporting " + player.getName() + " to " + targetServerInfo.getName() + ":" + message.target_player_uuid); + player.connect(targetServerInfo); + } + }); + } +} diff --git a/Commons/bungee/src/main/java/tc/oc/commons/bungee/restart/RestartListener.java b/Commons/bungee/src/main/java/tc/oc/commons/bungee/restart/RestartListener.java new file mode 100644 index 0000000..06e0dda --- /dev/null +++ b/Commons/bungee/src/main/java/tc/oc/commons/bungee/restart/RestartListener.java @@ -0,0 +1,106 @@ +package tc.oc.commons.bungee.restart; + +import java.time.Instant; +import java.util.logging.Logger; +import javax.annotation.Nullable; +import javax.inject.Inject; + +import com.google.common.eventbus.Subscribe; +import net.md_5.bungee.api.event.LoginEvent; +import net.md_5.bungee.api.event.PlayerDisconnectEvent; +import net.md_5.bungee.api.plugin.Listener; +import net.md_5.bungee.event.EventHandler; +import tc.oc.api.docs.Server; +import tc.oc.api.docs.virtual.ServerDoc; +import tc.oc.api.minecraft.servers.LocalServerReconfigureEvent; +import tc.oc.api.minecraft.users.OnlinePlayers; +import tc.oc.commons.core.logging.Loggers; +import tc.oc.commons.core.plugin.PluginFacet; +import tc.oc.commons.core.restart.CancelRestartEvent; +import tc.oc.commons.core.restart.RequestRestartEvent; +import tc.oc.commons.core.restart.RestartConfiguration; +import tc.oc.commons.core.scheduler.ReusableTask; +import tc.oc.commons.core.scheduler.Scheduler; +import tc.oc.commons.core.scheduler.Task; + +public class RestartListener implements PluginFacet, Listener, Runnable { + + private final Logger logger; + private final RestartConfiguration config; + private final Server localServer; + private final OnlinePlayers onlinePlayers; + private final ReusableTask task; + + private @Nullable RequestRestartEvent.Deferral deferral; + + @Inject RestartListener(Loggers loggers, RestartConfiguration config, Server localServer, OnlinePlayers onlinePlayers, Scheduler scheduler) { + this.logger = loggers.get(getClass()); + this.config = config; + this.localServer = localServer; + this.onlinePlayers = onlinePlayers; + this.task = scheduler.createReusableTask(this); + } + + private boolean canRestart(int playerCount) { + // If server is on public DNS, it cannot restart + if(localServer.dns_enabled()) { + logger.info("Deferring restart because server is on public DNS"); + return false; + } + + // If we are still waiting for the server to empty, it cannot restart + final Instant deadline = localServer.dns_toggled_at().plus(config.emptyTimeout()); + if(playerCount > 0 && deadline.isAfter(Instant.now())) { + logger.info("Deferring restart until the server empties or until " + deadline + ", whichever is first"); + task.schedule(Task.Parameters.fromInstant(deadline)); + return false; + } + + // If we have given up waiting for the server to empty, it can restart. + // However, the kick-limit enforced by RestartManager may still prevent it from restarting. + return true; + } + + private void update(int playerCount) { + if(this.deferral != null && canRestart(playerCount)) { + final RequestRestartEvent.Deferral deferral = this.deferral; + this.deferral = null; + deferral.resume(); + } + } + + @Override + public void run() { + update(onlinePlayers.count()); + } + + @Subscribe + public void onRequestRestart(RequestRestartEvent event) { + if(event.priority() < ServerDoc.Restart.Priority.HIGH && !canRestart(onlinePlayers.count())) { + deferral = event.defer(getClass().getName()); + } + } + + @Subscribe + public void onCancelRestart(CancelRestartEvent event) { + deferral = null; + } + + @Subscribe + public void onReconfigure(LocalServerReconfigureEvent event) { + if(event.getOldConfig() != null && + event.getOldConfig().dns_enabled() && + !event.getNewConfig().dns_enabled()) run(); + } + + @EventHandler + synchronized public void onConnect(final LoginEvent event) { + this.update(onlinePlayers.count() + 1); + } + + @EventHandler + synchronized public void onDisconnect(final PlayerDisconnectEvent event) { + this.update(onlinePlayers.count() - 1); + } + +} diff --git a/Commons/bungee/src/main/java/tc/oc/commons/bungee/servers/LobbyTracker.java b/Commons/bungee/src/main/java/tc/oc/commons/bungee/servers/LobbyTracker.java new file mode 100644 index 0000000..e8f8574 --- /dev/null +++ b/Commons/bungee/src/main/java/tc/oc/commons/bungee/servers/LobbyTracker.java @@ -0,0 +1,101 @@ +package tc.oc.commons.bungee.servers; + +import java.util.Comparator; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.logging.Logger; +import java.util.stream.Stream; +import javax.annotation.Nullable; +import javax.inject.Inject; +import javax.inject.Singleton; + +import net.md_5.bungee.api.config.ServerInfo; +import tc.oc.api.docs.Server; +import tc.oc.api.docs.virtual.ServerDoc; +import tc.oc.api.model.ModelListener; +import tc.oc.commons.core.logging.Loggers; +import tc.oc.commons.core.plugin.PluginFacet; +import tc.oc.commons.core.stream.BiStream; + +import static java.util.stream.Collectors.toSet; + +/** + * Track available lobbies and choose the best lobby to receive players at any given time + */ +@Singleton +public class LobbyTracker implements ModelListener, PluginFacet { + + private final Logger logger; + private final Server localServer; + private final ServerTracker serverTracker; + + protected final Map activeLobbies = new ConcurrentHashMap<>(); + + @Inject LobbyTracker(Loggers loggers, Server localServer, ServerTracker serverTracker) { + this.logger = loggers.get(getClass()); + this.localServer = localServer; + this.serverTracker = serverTracker; + } + + /** + * Return the set of protocol versions supported by at least one active lobby + */ + public Stream supportedProtocols() { + return activeLobbies.keySet() + .stream() + .flatMap(server -> server.protocol_versions().stream()); + } + + /** + * Choose the best lobby to join with the given protocol + */ + public Optional chooseLobby(int protocol) { + return chooseLobby(protocol, null); + } + + /** + * Choose the best lobby to join with the given protocol, + * besides the given excluded lobby. + */ + public Optional chooseLobby(int protocol, @Nullable ServerInfo excluded) { + if(activeLobbies.isEmpty()) { + logger.severe("No active lobbies"); + return Optional.empty(); + } + + return BiStream.from(activeLobbies) + .filterKeys(server -> server.protocol_versions().contains(protocol)) + .filterValues(info -> !Objects.equals(excluded, info)) + .maxByKey(Comparator.comparing(Server::num_online)) + .map(Map.Entry::getValue); + } + + @HandleModel + public void serverUpdated(@Nullable Server before, @Nullable Server after, Server latest) { + register(latest); + } + + private void register(Server server) { + final Optional info = Optional.of(server) + .filter(this::isActiveLobby) + .flatMap(serverTracker::serverInfo); + if(info.isPresent()) { + if(activeLobbies.put(server, info.get()) == null) { + logger.fine("Added lobby " + server.bungee_name()); + } + } else { + if(activeLobbies.remove(server) != null) { + logger.fine("Removed lobby " + server.bungee_name()); + } + } + } + + private boolean isActiveLobby(Server server) { + return server.role() == ServerDoc.Role.LOBBY && + server.restart_queued_at() == null && + localServer.datacenter().equals(server.datacenter()); + } +} diff --git a/Commons/bungee/src/main/java/tc/oc/commons/bungee/servers/ServerTracker.java b/Commons/bungee/src/main/java/tc/oc/commons/bungee/servers/ServerTracker.java new file mode 100644 index 0000000..dc254d0 --- /dev/null +++ b/Commons/bungee/src/main/java/tc/oc/commons/bungee/servers/ServerTracker.java @@ -0,0 +1,103 @@ +package tc.oc.commons.bungee.servers; + +import java.net.InetSocketAddress; +import java.util.Objects; +import java.util.Optional; +import java.util.logging.Logger; +import javax.annotation.Nullable; +import javax.inject.Inject; +import javax.inject.Provider; +import javax.inject.Singleton; + +import net.md_5.bungee.api.ProxyServer; +import net.md_5.bungee.api.config.ServerInfo; +import net.md_5.bungee.api.connection.ProxiedPlayer; +import net.md_5.bungee.config.Configuration; +import tc.oc.api.docs.Server; +import tc.oc.api.docs.virtual.ServerDoc; +import tc.oc.api.model.ModelListener; +import tc.oc.api.servers.ServerStore; +import tc.oc.commons.core.logging.Loggers; +import tc.oc.commons.core.plugin.PluginFacet; + +/** + * Keeps Bungee's list of {@link ServerInfo}s in sync with the {@link Server}s + * in the {@link ServerStore}, and provides conversions between them. + */ +@Singleton +public class ServerTracker implements ModelListener, PluginFacet { + + protected final Logger logger; + protected final ProxyServer proxy; + protected final boolean interDatacenter; + protected final Server localServer; + protected final Provider serverStore; // Circular dependency + + @Inject ServerTracker(Loggers loggers, Configuration configuration, ProxyServer proxy, Server localServer, Provider serverStore) { + this.localServer = localServer; + this.logger = loggers.get(getClass()); + this.proxy = proxy; + this.interDatacenter = configuration.getBoolean("inter-datacenter", true); + this.serverStore = serverStore; + } + + /** + * Return the {@link Server} document for the given proxy record + */ + public Server byInfo(ServerInfo info) { + return serverStore.get().byBungeeName(info.getName()); + } + + /** + * Return the {@link Server} document for the server that the given player is currently connected to + */ + public Server byPlayer(ProxiedPlayer player) { + return byInfo(player.getServer().getInfo()); + } + + /** + * If the given {@link Server} is connectable from this proxy, return it's {@link ServerInfo} + */ + public Optional serverInfo(@Nullable ServerDoc.Identity server) { + final Server complete = serverStore.get().byId(server._id()); + if(isConnectable(complete)) { + ServerInfo info = proxy.getServerInfo(server.bungee_name()); + if(!isInfoCorrect(complete, info)) { + logger.fine("Registering " + server.bungee_name()); + info = proxy.constructServerInfo( + server.bungee_name(), + new InetSocketAddress(complete.ip(), complete.current_port()), + "", + false + ); + proxy.getConfig().addServer(info); + } + return Optional.of(info); + } else { + if(proxy.getConfig().removeServerNamed(server.bungee_name()) != null) { + logger.fine("Unregistering " + server.bungee_name()); + } + return Optional.empty(); + } + } + + private boolean isConnectable(Server server) { + return server.alive() && + server.online() && + server.ip() != null && + server.current_port() != null && + server.bungee_name() != null && server.bungee_name().length() != 0 && + (Objects.equals(server.datacenter(), localServer.datacenter()) || interDatacenter); + } + + private boolean isInfoCorrect(Server server, @Nullable ServerInfo info) { + return info != null && + server.ip().equals(info.getAddress().getHostString()) && + server.current_port().equals(info.getAddress().getPort()); + } + + @HandleModel + public void serverUpdated(@Nullable Server before, @Nullable Server after, Server latest) { + serverInfo(latest); + } +} diff --git a/Commons/bungee/src/main/java/tc/oc/commons/bungee/sessions/MojangSessionServiceCommands.java b/Commons/bungee/src/main/java/tc/oc/commons/bungee/sessions/MojangSessionServiceCommands.java new file mode 100644 index 0000000..7417fec --- /dev/null +++ b/Commons/bungee/src/main/java/tc/oc/commons/bungee/sessions/MojangSessionServiceCommands.java @@ -0,0 +1,57 @@ +package tc.oc.commons.bungee.sessions; + +import javax.annotation.Nullable; +import javax.inject.Inject; + +import com.sk89q.minecraft.util.commands.Command; +import com.sk89q.minecraft.util.commands.CommandContext; +import com.sk89q.minecraft.util.commands.CommandException; +import com.sk89q.minecraft.util.commands.CommandPermissions; +import net.md_5.bungee.api.ChatColor; +import net.md_5.bungee.api.CommandSender; +import net.md_5.bungee.api.chat.ComponentBuilder; +import tc.oc.commons.core.commands.Commands; + +public class MojangSessionServiceCommands implements Commands { + private final MojangSessionServiceMonitor monitor; + + @Inject MojangSessionServiceCommands(MojangSessionServiceMonitor monitor) { + this.monitor = monitor; + } + + @Command( + aliases = {"session"}, + desc = "Session status control", + usage = "[on|off|clear]", + max = 1 + ) + @CommandPermissions("bungeecord.command.session") + public void session(final CommandContext args, CommandSender sender) throws CommandException { + if (args.argsLength() == 1) { + String arg = args.getString(0); + SessionState newState = parseSessionStateCommand(arg); + if (newState == null) throw new CommandException("Unknown session state command: " + arg); + + sender.sendMessage(new ComponentBuilder("Old Force Status: " + monitor.getForceState()).color(ChatColor.LIGHT_PURPLE).create()); + + monitor.setForceState(newState); + } + + sender.sendMessage(new ComponentBuilder("Current Force Status: " + monitor.getForceState()).color(ChatColor.GOLD).create()); + sender.sendMessage(new ComponentBuilder("Current Session Status: " + monitor.getDiscoveredState()).color(ChatColor.GOLD).create()); + } + + private static @Nullable SessionState parseSessionStateCommand(String command) { + switch (command) { + case "on": + return SessionState.ONLINE; + case "off": + return SessionState.OFFLINE; + case "clear": + case "none": + return SessionState.ABSENT; + default: + return null; + } + } +} diff --git a/Commons/bungee/src/main/java/tc/oc/commons/bungee/sessions/MojangSessionServiceMonitor.java b/Commons/bungee/src/main/java/tc/oc/commons/bungee/sessions/MojangSessionServiceMonitor.java new file mode 100644 index 0000000..c7d0b38 --- /dev/null +++ b/Commons/bungee/src/main/java/tc/oc/commons/bungee/sessions/MojangSessionServiceMonitor.java @@ -0,0 +1,95 @@ +package tc.oc.commons.bungee.sessions; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.logging.Logger; +import javax.inject.Inject; +import javax.inject.Singleton; + +import net.md_5.bungee.api.ChatColor; +import java.time.Duration; +import tc.oc.commons.core.logging.Loggers; +import tc.oc.commons.core.plugin.PluginFacet; +import tc.oc.minecraft.api.scheduler.Tickable; + +@Singleton +public class MojangSessionServiceMonitor implements PluginFacet, Tickable { + private static final String URL = "https://sessionserver.mojang.com"; + private static final Duration TIMEOUT = Duration.ofSeconds(5); + private static final java.time.Duration POLLING_INTERVAL = java.time.Duration.ofSeconds(10); + + private final Logger logger; + private SessionState forceState = SessionState.ABSENT; + private SessionState state = SessionState.ONLINE; // assume online + + @Inject MojangSessionServiceMonitor(Loggers loggers) { + this.logger = loggers.get(getClass()); + } + + @Override + public java.time.Duration tickPeriod() { + return POLLING_INTERVAL; + } + + /** + * Gets the session state. State can either be forced or discovered by + * periodically pinging the Mojang session server. + */ + public SessionState getState() { + if (forceState != SessionState.ABSENT) { + return forceState; + } else { + return state; + } + } + + /** + * Get the forced session state. + */ + public SessionState getForceState() { + return forceState; + } + + /** + * Set the forced session state. This will override whatever state has been + * discovered. + */ + public void setForceState(SessionState state) { + forceState = state; + } + + /** + * Gets the session state discovered by pinking Mojang's session server. + */ + public SessionState getDiscoveredState() { + return state; + } + + @Override + public void tick() { + int response = getResponseCode(URL); + SessionState newState = response == 200 ? SessionState.ONLINE : SessionState.OFFLINE; + + if(state != newState) { + logger.warning("Status (" + response + ") changed from " + state + ChatColor.RESET + " to " + newState); + state = newState; + } + } + + private int getResponseCode(String urlString) { + try { + URL url = new URL(urlString); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + + connection.setConnectTimeout((int) TIMEOUT.toMillis()); + connection.setReadTimeout((int) TIMEOUT.toMillis()); + connection.setRequestMethod("GET"); + + connection.connect(); + return connection.getResponseCode(); + } catch (IOException e) { + return -1; + } + } +} diff --git a/Commons/bungee/src/main/java/tc/oc/commons/bungee/sessions/SessionState.java b/Commons/bungee/src/main/java/tc/oc/commons/bungee/sessions/SessionState.java new file mode 100644 index 0000000..62040ad --- /dev/null +++ b/Commons/bungee/src/main/java/tc/oc/commons/bungee/sessions/SessionState.java @@ -0,0 +1,23 @@ +package tc.oc.commons.bungee.sessions; + +import net.md_5.bungee.api.ChatColor; + +public enum SessionState { + ABSENT(ChatColor.YELLOW), + ONLINE(ChatColor.GREEN), + OFFLINE(ChatColor.RED); + + SessionState(ChatColor color) { + this.color = color; + } + + public ChatColor getColor() { + return this.color; + } + + public String toString() { + return color + super.toString(); + } + + private final ChatColor color; +} diff --git a/Commons/bungee/src/main/resources/config.yml b/Commons/bungee/src/main/resources/config.yml new file mode 100644 index 0000000..0a340d9 --- /dev/null +++ b/Commons/bungee/src/main/resources/config.yml @@ -0,0 +1,19 @@ +# Automatic restart +restart: + interval: 1m # Polling interval + # uptime: 12h # Queue a restart automatically after this much uptime + # memory: 2560 # Queue a restart if heap gets this big + # defer-timeout: 9h # Maximum time restart can be delayed after request + # kick-limit: 5 # Maximum number of players that can be disconnected to restart + # empty-timeout: 9h # Maximum time restart can be delayed after new player connections have been blocked + +# If false, only servers in the same datacenter will be pulled from the +# API. This allows production servers to be filtered out in a development +# environment, where failed DNS lookups of their hostnames can cause +# massive delays (literally 30s of blocking per server). +inter-datacenter: true + +datadog: + enabled: false + host: localhost + port: 8125 diff --git a/Commons/bungee/src/main/resources/plugin.yml b/Commons/bungee/src/main/resources/plugin.yml new file mode 100644 index 0000000..e95fc87 --- /dev/null +++ b/Commons/bungee/src/main/resources/plugin.yml @@ -0,0 +1,5 @@ +name: ${plugin.prefix} +main: ${plugin.mainClass} +version: ${project.version}-${git.commit.id.abbrev} +author: Overcast Network +depends: [API, Raven] diff --git a/Commons/core/build.xml b/Commons/core/build.xml new file mode 100644 index 0000000..3e6feb4 --- /dev/null +++ b/Commons/core/build.xml @@ -0,0 +1,16 @@ + + + Build file for the core module of Commons. Currently only used for pulling translations from CrowdIn. + + + + + + + + + + + + + diff --git a/Commons/core/pom.xml b/Commons/core/pom.xml new file mode 100644 index 0000000..113ac30 --- /dev/null +++ b/Commons/core/pom.xml @@ -0,0 +1,92 @@ + + + 4.0.0 + + + commons + tc.oc + ../pom.xml + 1.11-SNAPSHOT + + + commons-core + + + + tc.oc + api-minecraft + ${project.version} + + + + com.datadoghq + java-dogstatsd-client + + + + junit + junit + 4.10 + test + + + + + + + org.apache.maven.plugins + maven-antrun-plugin + 1.8 + + + generate-resources + + + + + + + run + + + + + + org.apache.maven.plugins + maven-shade-plugin + 2.1 + + + package + + shade + + + + + com.datadoghq:java-dogstatsd-client + + + + + + + + + + src/main/i18n/translations + + **/*.properties + + + + src/main/i18n/templates + + **/*.properties + + + + + diff --git a/Commons/core/src/main/i18n/templates/adminchat/AdminChatErrors.properties b/Commons/core/src/main/i18n/templates/adminchat/AdminChatErrors.properties new file mode 100644 index 0000000..1a980c0 --- /dev/null +++ b/Commons/core/src/main/i18n/templates/adminchat/AdminChatErrors.properties @@ -0,0 +1,8 @@ +commands.noPermission = You don't have permission. + +# {0} the usage as specified by the command +commands.incorrectUsage = Usage: {0} + +commands.unknownError = An unknown error occured. Please notify an administrator. + +commands.noMessage = You must provide a message. diff --git a/Commons/core/src/main/i18n/templates/adminchat/AdminChatMessages.properties b/Commons/core/src/main/i18n/templates/adminchat/AdminChatMessages.properties new file mode 100644 index 0000000..a279943 --- /dev/null +++ b/Commons/core/src/main/i18n/templates/adminchat/AdminChatMessages.properties @@ -0,0 +1,3 @@ +channel.switch.success = Changed default channel to administrator chat. +channel.switch.alreadySwitched = Administrator chat is already your default channel. +channel.message.success = Message sent. diff --git a/Commons/core/src/main/i18n/templates/chatmoderator/ChatModeratorErrors.properties b/Commons/core/src/main/i18n/templates/chatmoderator/ChatModeratorErrors.properties new file mode 100644 index 0000000..52240d2 --- /dev/null +++ b/Commons/core/src/main/i18n/templates/chatmoderator/ChatModeratorErrors.properties @@ -0,0 +1 @@ +noPermissions = You do not have permission. diff --git a/Commons/core/src/main/i18n/templates/chatmoderator/ChatModeratorMessages.properties b/Commons/core/src/main/i18n/templates/chatmoderator/ChatModeratorMessages.properties new file mode 100644 index 0000000..d6048cb --- /dev/null +++ b/Commons/core/src/main/i18n/templates/chatmoderator/ChatModeratorMessages.properties @@ -0,0 +1,2 @@ +messages.warning.notSent = This message was not sent to some players because it contained potentially offensive content. +messages.reload.success = Successfully reloaded config and registered all filters and zones. diff --git a/Commons/core/src/main/i18n/templates/commons/Commons.properties b/Commons/core/src/main/i18n/templates/commons/Commons.properties new file mode 100644 index 0000000..d3966ab --- /dev/null +++ b/Commons/core/src/main/i18n/templates/commons/Commons.properties @@ -0,0 +1,206 @@ +# {0} = seconds until kick +afk.warn = You will be disconnected for inactivity in {0} seconds +afk.kick = You were disconnected for inactivity + +command.admin.queueRestart.restartingNow = Server will restart now. +command.admin.queueRestart.restartQueued = Server will restart at the next available opportunity. +command.admin.cancelRestart.restartUnqueued = Queued restart countdown cancelled. +command.admin.cancelRestart.noActionTaken = No active or queued restart countdowns found. + +command.error.notEnoughArguments = Not enough arguments +command.error.unexpectedArgument = Unexpected argument '{0}' +command.error.invalidTimePeriod = Invalid time period '{0}' +command.error.invalidNumber = Invalid number '{0}' +command.error.invalidPage = There is no page {0}. Pages run from 1 to {1}. +command.error.emptyResult = Empty result +command.onlyPlayers = You must be a player to use this command. +command.error.internal = Sorry, there was an internal error while processing your command + +console = Console + +# exactly 2 +misc.list.pair = {0} and {1} +# start of 3 or more +misc.list.start = {0}, {1} +# middle of 4 or more +misc.list.middle = {0}, {1} +# end of 3 or more +misc.list.end = {0}, and {1} + +misc.authorship = {0} by {1} + +time.interval.millisecond = {0} millisecond +time.interval.milliseconds = {0} milliseconds +time.interval.second = {0} second +time.interval.seconds = {0} seconds +time.interval.minute = {0} minute +time.interval.minutes = {0} minutes +time.interval.hour = {0} hour +time.interval.hours = {0} hours +time.interval.day = {0} day +time.interval.days = {0} days +time.interval.week = {0} week +time.interval.weeks = {0} weeks +time.interval.month = {0} month +time.interval.months = {0} months +time.interval.year = {0} year +time.interval.years = {0} years + +time.ago = {0} ago +time.fromNow = {0} from now +time.for = for {0} + +prefixed.tip = Tip +prefixed.news = News +prefixed.alert = Alert +prefixed.info = Info +prefixed.fact = Fact +prefixed.chat = Chat + +navigator.title = Navigator + +servers.title.generic = Servers +servers.title.lobbies = Lobby Selector +servers.title.tourneyMatch = Match Servers +servers.loading = Loading servers... +servers.stoppedLoading = Stopped loading servers +servers.connecting = Connecting you to {0} +servers.lobby = Lobby +servers.online = online +servers.offline = offline +servers.restarting = restarting... +servers.backToLobby = Back to Lobby +servers.currentMap = Map: {0} +servers.nextMap = Next: {0} +servers.cannotChange = You cannot change servers right now +servers.notAllowed = You are not allowed on this server + +game.title = Games +game.play = Play {0} +game.replay = Replay {0} +game.replayMaybe = Type {0} or wait until this match ends +game.leave = Leave {0} +game.joining = Joining {0}... +game.rejoining = Joining a {0} server with more players... +game.alreadyPlaying = You are already playing {0} +game.notPlaying = You are not playing any game right now +game.waitingForPlayers = Waiting for {0} more players to join {1}... +game.left = Left {0} +game.unknown = Unknown game '{0}' +game.offline = Sorry, {0} is offline right now +game.empty = There are no {0} matches happening right now +game.choose = Click on a game below, or type {0} or {1} +game.numOnline = {0} players +game.numPlaying = {0} playing +game.numWatching = {0} watching +game.numQueued = {0} waiting + +# These need to fit on one line in all languages, so keep them as short as possible +game.description.skywars = 8 player battle in the sky +game.description.survival = Be the last one standing +game.description.arcade = Quick and easy fun +game.description.micro = 5 players, 5 minutes +game.description.ranked = Climb the leaderboard +game.description.greek = Team games for 64+ players +game.description.mini = Team games for 16-32 players +game.description.ctw = Capture the Wool +game.description.ctf = Capture the Flag +game.description.conquer = Team Deathmatch and King of the Hill +game.description.destroy = Destroy the Core/Monument +game.description.riot = Free-for-all Deathmatch +game.description.blitz = Deathmatch with no respawning +game.description.rage = One hit kills +game.description.nostalgia = Classic maps from the good old days +game.description.mapdev = Map development and playtesting +game.description.residence = Community map development and playtesting +game.description.party = Special events + +tip.teleportTo = Teleport to {0} +tip.connectTo = Connect to {0} + +# {0} = Sender +# {1} = Recipient +# {2} = Relative time +privateMessage.from = From {0} +privateMessage.to = To {1} +privateMessage.from.to = From {0} to {1} +privateMessage.from.time = From {0} {2} +privateMessage.from.to.time = From {0} to {1} {2} + + +# {0} = Recipient +# {1} = Trophy +trophies.list.other = {0}'s Trophies +trophies.list.self = Your Trophies +trophies.grant.success = {0} has been granted the {1} trophy +trophies.grant.alreadyOwns = {0} already owns the {1} trophy +trophies.revoke.success = {0} has lost the {1} trophy +trophies.revoke.doesNotOwn = {0} does not own the {1} trophy + +trophies.notFound = Unable to find trophy named '{0}' + +whitelist.default = Default whitelist loaded +whitelist.empty = Whitelist is empty +whitelist.kicked = You are not whitelisted on this server + +# {0} = enabled/disabled +whitelist.status = Whitelist is {0} + +# {0} = number of players +whitelist.size = {0} whitelisted players +whitelist.addMulti = Added {0} players to the whitelist +whitelist.kickMulti = Kicked {0} players who were not whitelisted + +# {0} = player name +whitelist.add = {0} added to whitelist +whitelist.remove = {0} removed from whitelist +whitelist.notFound = No whitelisted player named '{0}' + +# {0} = the number of appeals +# {1} = singular/plural substitution +appealNotification.count = You have - {0} - unread {1} +misc.appeals.singular = appeal +misc.appeals.plural = appeals + +misc.enabled = enabled +misc.disabled = disabled + +misc.unknown = Unknown +misc.thankYou = Thank you. + +misc.leftClick = Left Click +misc.rightClick = Right Click + +misc.clickHere = Click here + +# As in, a website URL +misc.link = Link + +entity.ThrownEnderpearl.name = Ender Pearl + +# {0} = player name +# {1} = number of raindrops +# {2} = "raindrop/raindrops" +raindrops.balance.self = You have {1} {2} +raindrops.balance.other = {0} has {1} {2} +raindrops.singular = raindrop +raindrops.plural = raindrops + +material.Iron = Iron +material.gold = Gold +material.quartz = Quartz + +punishment.prefix = Punish +punishment.lookup = {0}'s Punishments +punishment.warning = WARNING + +punishment.screen.warn = You were warned by a staff member {0} +punishment.screen.kick = You were kicked by a staff member {0} +punishment.screen.ban = You were banned by a staff member {0} +punishment.screen.forum_warn = You were forum warned by a staff member {0} +punishment.screen.forum_ban = You were forum banned by a staff member {0} +punishment.screen.tourney_ban = You were tournament banned by a staff member {0} +punishment.screen.suspension = You were suspended by a staff member {0} +punishment.screen.unknown = You were issued an unknown punishment by a staff member {0} +punishment.screen.rules = Read our server rules at {0} +punishment.screen.appeal = or contest your punishment at {0} diff --git a/Commons/core/src/main/i18n/templates/lobby/LobbyErrors.properties b/Commons/core/src/main/i18n/templates/lobby/LobbyErrors.properties new file mode 100644 index 0000000..3c4436f --- /dev/null +++ b/Commons/core/src/main/i18n/templates/lobby/LobbyErrors.properties @@ -0,0 +1,5 @@ +gizmo.gun.empty = No Raindrops available to shoot + +raindrops.purchase.fail = You do not have enough Raindrops to purchase this + +purchase.purchase.fail = Purchase failed diff --git a/Commons/core/src/main/i18n/templates/lobby/LobbyMessages.properties b/Commons/core/src/main/i18n/templates/lobby/LobbyMessages.properties new file mode 100644 index 0000000..1f9fc3e --- /dev/null +++ b/Commons/core/src/main/i18n/templates/lobby/LobbyMessages.properties @@ -0,0 +1,56 @@ +gizmo.empty.name = Empty +gizmo.empty.description = Unequip the current gizmo + +gizmo.popper.name = Popper +gizmo.popper.description = Clear players with a satisfying pop + +gizmo.rocket.name = Player Rocketer +gizmo.rocket.description = Hide players by launching them into colorful fireworks + +gizmo.gun.name = Raindrop Gun +gizmo.gun.description = Gift raindrops with a punch :D + +gizmo.chicken.name = Chickenifier5000 +gizmo.chicken.description = bok B'GAWK + + +# {0} = number of players chickened +gizmo.chicken.raindropsResult = chicken'd a total of {0} players + +gizmo.gun.raindropsResult = raindrop gun gift + +# {0} = number of players popped +gizmo.popper.raindropsResult = popped a total of {0} players + +# {0} = number of players rocketed +gizmo.rocket.raindropsResult = rocketed a total of {0} players + +gizmo.currentlyPurchasing = You are currently purchasing this gizmo, please wait + +# {0} = Name of network +welcome = Welcome to the {0} + +# {0} = servers.lobby +# {1} = welcome.sign +welcome.instructions = You are in the {0}. To play, punch a {1} on the wall, or right-click the {2} in your hand. +welcome.portal = portal +welcome.sign = sign +welcome.serverPicker = server picker + +# {0} = website URL +welcome.visitWebsite = More at {0} + +# {0} = text from trial.freeTrial +# {1} = days remaining +trial.remaining.plural = You have a {0} of premium membership for {1} more days! +trial.remaining.singular = You have a {0} of premium membership for {1} more day! +trial.freeTrial = free trial + +# {0} = text from trial.joinFull +# {1} = text from trial.chooseTeam +trial.details = Premium members can {0} and {1}. +trial.joinFull = join full servers +trial.chooseTeam = choose their team + +# {0} = shop URL +trial.upgrade = Visit {0} to extend your membership. diff --git a/Commons/core/src/main/i18n/templates/lobby/LobbyMiscellaneous.properties b/Commons/core/src/main/i18n/templates/lobby/LobbyMiscellaneous.properties new file mode 100644 index 0000000..eb537ba --- /dev/null +++ b/Commons/core/src/main/i18n/templates/lobby/LobbyMiscellaneous.properties @@ -0,0 +1,9 @@ +gizmos = Gizmos + +# {0} the gizmo to shop for +gizmos.shopFor = Shop for {0} + +# {0} the colored name of the gizmo about to be purchased +purchase.purchase = Purchase {0} + +purchase.cancel = Cancel diff --git a/Commons/core/src/main/i18n/templates/lobby/LobbyUI.properties b/Commons/core/src/main/i18n/templates/lobby/LobbyUI.properties new file mode 100644 index 0000000..92605b8 --- /dev/null +++ b/Commons/core/src/main/i18n/templates/lobby/LobbyUI.properties @@ -0,0 +1,16 @@ +# {0} the currently active gizmo's human-readable name +gizmo.current = Current gizmo: {0} + +gizmo.purchasing = Purchasing... +gizmo.purchasing.cancel = Purchase cancelled +gizmo.purchased = Purchased +gizmo.purchased.success = Purchased {0} for {1} raindrops + +# {0} The cost in raindrops of the gizmo +gizmo.cost = {0} Raindrops + +# {0} the colored name of the gizmo that was equipped +gizmo.equip = Gizmo equipped: {0} + +# the server IP that they are playing on +lobby.news = You are now playing on: {0} diff --git a/Commons/core/src/main/i18n/templates/pgm/PGMDeath.properties b/Commons/core/src/main/i18n/templates/pgm/PGMDeath.properties new file mode 100644 index 0000000..5a9060b --- /dev/null +++ b/Commons/core/src/main/i18n/templates/pgm/PGMDeath.properties @@ -0,0 +1,191 @@ +# suppress inspection "UnusedProperty" for whole file + +death.predictedSuffix = (predicted) + +# {0} = victim name +# {1} = killer name (always a player) +# {2} = weapon +# {3} = mob +# {4} = distance in blocks + +death.cactus = {0} tried to hug a cactus +death.drown = {0} forgot to breathe +death.lightning = {0} was struck by lightning +death.starve = {0} starved to death +death.suffocate = {0} suffocated +death.poison = {0} died of poisoning +death.wither = {0} withered away +death.unknown = {0} died from unknown causes +death.generic = {0} died + +death.block = {0} was killed by a {2} block +death.entity = {0} was killed by a {2} +death.mob = {0} was killed by a {3} + +death.player = {0} was killed by {1} +death.player.block = {0} was killed by {1}'s {2} block +death.player.entity = {0} was killed by {1}'s {2} +death.player.mob = {0} was killed by {1}'s {3} + +death.magic = {0} was killed by magic +death.magic.potion = {0} was killed by a potion of {2} +death.magic.mob = {0} was killed by a {3}'s magic +death.magic.mob.potion = {0} was killed by a {3}'s potion of {2} +death.magic.player = {0} was killed by {1}'s magic +death.magic.player.potion = {0} was killed by {1}'s potion of {2} +death.magic.player.mob = {0} was killed by the magic of {1}'s {3} +death.magic.player.mob.potion = {0} was killed by a potion of {2} from {1}'s {3} + +death.squash = {0} was squashed to death +death.squash.player = {0} was squashed dead by {1} +death.squash.entity = {0} was squashed by a falling {2} +death.squash.player.entity = {0} was squashed by {1}'s falling {2} + +death.melee.player = {0} felt the fury of {1}'s fists +death.melee.player.item = {0} was slain by {1}'s {2} +death.melee.mob = {0} was slain by a {3} +death.melee.mob.item = {0} was slain by a {3} with a {2} +death.melee.player.mob = {0} was slain by {1}'s {3} +death.melee.player.mob.item = {0} was slain by {1}'s {3} with a {2} + +death.projectile = {0} was hit by a stray arrow +death.projectile.entity = {0} was hit by a stray {2} +death.projectile.mob = {0} was shot by a {3} +death.projectile.mob.entity = {0} was shot by a {2} from a {3} +death.projectile.player.distance = {0} was shot by {1} from {4} blocks +death.projectile.player.distance.snipe = {0} was sniped by {1} from {4} blocks +death.projectile.player.entity.distance = {0} was shot by {1} with a {2} from {4} blocks +death.projectile.player.entity.distance.snipe = {0} was sniped by {1} with a {2} from {4} blocks +death.projectile.player.mob = {0} was shot by {1}'s {3} +death.projectile.player.mob.entity = {0} was shot by a {2} from {1}'s {3} + +death.explosive = {0} blew up +death.explosive.entity = {0} was blown up by a {2} +death.explosive.entity.PrimedTnt = {0} was blown up by TNT +death.explosive.mob = {0} was blown up by a {3} +death.explosive.mob.Creeper = {0} tried to hug a creeper +death.explosive.player = {0} was blown up by {1} +death.explosive.player.distance = {0} was blown up by {1} from {4} blocks +death.explosive.player.entity = {0} was blown up by {1}'s {2} +death.explosive.player.entity.distance = {0} was blown up by {1}'s {2} from {4} blocks +death.explosive.player.mob = {0} was blown up by {1}'s {3} +death.explosive.player.mob.Creeper = {0} tried to hug {1}'s creeper + +death.fire = {0} went up in flames +death.fire.entity = {0} was burnt to a crisp by a flaming {2} +death.fire.block = {0} was burnt to a crisp by {2} +death.fire.mob = {0} was burnt to a crisp by a flaming {3} +death.fire.player = {0} was burnt to a crisp by {1} +death.fire.player.entity = {0} was burnt to a crisp by {1}'s flaming {2} +death.fire.player.block = {0} was burnt to a crisp by {1}'s {2} +death.fire.player.mob = {0} was burnt to a crisp by {1}'s flaming {3} + +death.fall.ground.0 = {0} went splat +death.fall.ground.1 = {0} hit the ground too hard +death.fall.ground.2 = {0} fell off a high place +death.fall.ground.distance.0 = {0} went splat from {4} blocks up +death.fall.ground.distance.1 = {0} hit the ground from {4} blocks up +death.fall.ground.distance.2 = {0} fell {4} blocks and died +death.fall.ground.rare.0 = {0} forgot how to fly +death.fall.ground.rare.1 = {0} just learned about gravity +death.fall.ground.tripped = {0} tripped and fell +death.fall.ground.orbit.distance = {0} fell out of a {4} block orbit + +death.fall.lava = {0} fell into lava +death.fall.void = {0} fell out of the world + +death.fall.ground.melee.mob = {0} was knocked off a high place by a {3} +death.fall.lava.melee.mob = {0} was knocked into lava by a {3} +death.fall.void.melee.mob = {0} was knocked out of the world by a {3} + +death.fall.ground.melee.player = {0} was punched off a high place by {1} +death.fall.lava.melee.player = {0} was punched into lava by {1} +death.fall.void.melee.player = {0} was punched out of the world by {1} + +death.fall.ground.melee.player.item = {0} was knocked off a high place by {1}'s {2} +death.fall.lava.melee.player.item = {0} was knocked into lava by {1}'s {2} +death.fall.void.melee.player.item = {0} was knocked out of the world by {1}'s {2} + +death.fall.ground.melee.player.mob = {0} was knocked off a high place by {1}'s {3} +death.fall.lava.melee.player.mob = {0} was knocked into lava by {1}'s {3} +death.fall.void.melee.player.mob = {0} was knocked out of the world by {1}'s {3} + +death.fall.ground.projectile = {0} was shot off a high place by a stray arrow +death.fall.lava.projectile = {0} was shot into lava by a stray arrow +death.fall.void.projectile = {0} was shot out of the world by a stray arrow + +death.fall.ground.projectile.entity = {0} was shot off a high place by a stray {2} +death.fall.lava.projectile.entity = {0} was shot into lava by a stray {2} +death.fall.void.projectile.entity = {0} was shot out of the world by a stray {2} + +death.fall.ground.projectile.mob = {0} was shot off a high place by a {3} +death.fall.lava.projectile.mob = {0} was shot into lava by a {3} +death.fall.void.projectile.mob = {0} was shot out of the world by a {3} + +death.fall.ground.projectile.mob.entity = {0} was shot off a high place by a {2} from a {3} +death.fall.lava.projectile.mob.entity = {0} was shot into lava by a {2} from a {3} +death.fall.void.projectile.mob.entity = {0} was shot out of the world by a {2} from a {3} + +death.fall.ground.projectile.player.distance = {0} was shot off a high place by {1} from {4} blocks +death.fall.lava.projectile.player.distance = {0} was shot into lava by {1} from {4} blocks +death.fall.void.projectile.player.distance = {0} was shot out of the world by {1} from {4} blocks + +death.fall.ground.projectile.player.distance.snipe = {0} was sniped off a high place by {1} from {4} blocks +death.fall.lava.projectile.player.distance.snipe = {0} was sniped into lava by {1} from {4} blocks +death.fall.void.projectile.player.distance.snipe = {0} was sniped out of the world by {1} from {4} blocks + +death.fall.ground.projectile.player.entity.distance = {0} was shot off a high place by {1} with a {2} from {4} blocks +death.fall.lava.projectile.player.entity.distance = {0} was shot into lava by {1} with a {2} from {4} blocks +death.fall.void.projectile.player.entity.distance = {0} was shot out of the world by {1} with a {2} from {4} blocks + +death.fall.ground.projectile.player.entity.distance.snipe = {0} was sniped off a high place by {1} with a {2} from {4} blocks +death.fall.lava.projectile.player.entity.distance.snipe = {0} was sniped into lava by {1} with a {2} from {4} blocks +death.fall.void.projectile.player.entity.distance.snipe = {0} was sniped out of the world by {1} with a {2} from {4} blocks + +death.fall.ground.projectile.player.mob = {0} was shot off a high place by {1}'s {3} +death.fall.lava.projectile.player.mob = {0} was shot into lava by {1}'s {3} +death.fall.void.projectile.player.mob = {0} was shot out of the world by {1}'s {3} + +death.fall.ground.projectile.player.mob.entity = {0} was shot off a high place by a {2} from {1}'s {3} +death.fall.lava.projectile.player.mob.entity = {0} was shot into lava by a {2} from {1}'s {3} +death.fall.void.projectile.player.mob.entity = {0} was shot out of the world by a {2} from {1}'s {3} + +death.fall.ground.explosive = {0} was blown off a high place +death.fall.lava.explosive = {0} was blown into lava +death.fall.void.explosive = {0} was blown out of the world + +death.fall.ground.explosive.player = {0} was blown off a high place by {1} +death.fall.lava.explosive.player = {0} was blown into lava by {1} +death.fall.void.explosive.player = {0} was blown out of the world by {1} + +death.fall.ground.explosive.player.entity = {0} was blown off a high place by {1}'s {2} +death.fall.lava.explosive.player.entity = {0} was blown into lava by {1}'s {2} +death.fall.void.explosive.player.entity = {0} was blown out of the world by {1}'s {2} + +death.fall.ground.explosive.player.entity.distance = {0} was blown off a high place by {1}'s {2} from {4} blocks +death.fall.lava.explosive.player.entity.distance = {0} was blown into lava by {1}'s {2} from {4} blocks +death.fall.void.explosive.player.entity.distance = {0} was blown out of the world by {1}'s {2} from {4} blocks + +death.fall.ground.explosive.player.mob = {0} was blown off a high place by {1}'s {3} +death.fall.lava.explosive.player.mob = {0} was blown into lava by {1}'s {3} +death.fall.void.explosive.player.mob = {0} was blown out of the world by {1}'s {3} + +death.fall.ground.spleef.player = {0} was spleefed off a high place by {1} +death.fall.lava.spleef.player = {0} was spleefed into lava by {1} +death.fall.void.spleef.player = {0} was spleefed out of the world by {1} + +death.fall.ground.spleef.explosive = {0} was spleefed off a high place by {2} +death.fall.lava.spleef.explosive = {0} was spleefed into lava by {2} +death.fall.void.spleef.explosive = {0} was spleefed out of the world by {2} + +death.fall.ground.spleef.explosive.player = {0} was spleefed off a high place by {1}'s {2} +death.fall.lava.spleef.explosive.player = {0} was spleefed into lava by {1}'s {2} +death.fall.void.spleef.explosive.player = {0} was spleefed out of the world by {1}'s {2} + +death.fall.ground.spleef.explosive.player.distance = {0} was spleefed off a high place by {1}'s {2} from {4} blocks +death.fall.lava.spleef.explosive.player.distance = {0} was spleefed into lava by {1}'s {2} from {4} blocks +death.fall.void.spleef.explosive.player.distance = {0} was spleefed out of the world by {1}'s {2} from {4} blocks + +death.fall.ground.spleef.explosive.player.mob = {0} was spleefed off a high place by {1}'s {3} +death.fall.lava.spleef.explosive.player.mob = {0} was spleefed into lava by {1}'s {3} +death.fall.void.spleef.explosive.player.mob = {0} was spleefed out of the world by {1}'s {3} diff --git a/Commons/core/src/main/i18n/templates/pgm/PGMErrors.properties b/Commons/core/src/main/i18n/templates/pgm/PGMErrors.properties new file mode 100644 index 0000000..ef7b4a1 --- /dev/null +++ b/Commons/core/src/main/i18n/templates/pgm/PGMErrors.properties @@ -0,0 +1,131 @@ +command.admin.restart.matchRunning = You may not restart during a match. Use -f to override. + +command.admin.cycle.matchRunning = You may not cycle during a match. Use -f to override. + +command.admin.start.matchRunning = Match is already running. +command.admin.start.matchFinished = Match has already finished. +command.admin.start.unknownState = Match could not be started at this time. + +command.admin.end.unknownError = Match could not be ended at this time. + +command.admin.setNext.restartQueued = You may not set the next map when a restart is queued. Use -f to override. + +command.admin.mapreload.disabledAutomatic = This command is disabled. Maps will reload automatically if changes are detected. + +command.admin.skipto.invalidPoint = Specified rotation point is not valid. + +command.chat.team.alreadyDefault = Team chat is already your default channel. +command.chat.team.resolveError = Your team channel could not be resolved. Please try again in a few moments. + +command.class.select.classNotFound = No class matched query. +# {0} = the class +command.class.restricted = The {0} class is restricted +command.class.stickyClass = You may not change classes because your current class is sticky. +command.class.notEnabled = Classes are not enabled on this map. + +command.gameplay.myteam.notOnTeam = You are not on a team. +command.gameplay.leave.alreadyOnObservers = You are already on the observing team. +command.gameplay.leave.leaveDenied = You are not allowed to observe this match + +command.gameplay.join.alreadyOnTeam = You have already joined {0} +command.gameplay.join.alreadyJoined = You have already joined the match +command.gameplay.join.completelyFull = Sorry, {0} is completely full +command.gameplay.join.switchDisabled = You have permanently joined {0} for this match +command.gameplay.join.choiceDisabled = Team choosing is disabled +command.gameplay.join.choiceDenied = You are not allowed to choose your team +command.gameplay.join.matchStarted = This match has already started. Please wait for the next match. +command.gameplay.join.matchFinished = This match is over - the next match will start in a moment +command.gameplay.join.joinDenied = You are not allowed to join this match +command.gameplay.join.notSupported = No loaded module supports joining +command.gameplay.join.uneven = You cannot join because it would make the teams uneven + +# {0} = user being forced +command.team.force.exempt = {0} is exempt from being forced on to teams. + +command.team.shuffle.matchRunning = You may not shuffle teams during a match. + +# {0} = team name +command.team.alias.nameAlreadyUsed = A team by the name of {0} already exists. + +finalizedMatch.join.overMessage = Match is over +finalizedMatch.join.cycleMessage = Please wait for the server to cycle + +command.mapNotFound = No maps matched query. +command.moduleNotFound = The {0} module is not in use for this match +command.noTeams = Teams are not in use for this match +command.noPlayers = Players cannot join this match +command.teamNotFound = No teams matched query. +command.competitorNotFound = No competitors matched query. +command.rotationNotFound = No rotations matched query. +command.playerNotFound = No players matched query. +command.multiplePlayersFound = More than one player found! Use @ for exact matching. +command.unknownError = An unknown error has occurred. Please refer to the server console. + +gameplay.ffa.kickedForPremium = You were kicked from the match to make room for a premium player +gameplay.kickedForPremium = You were kicked off {0} to make room for a premium player +gameplay.kickedForBalance = You were moved to {0} to balance team sizes +gameplay.autoJoinSwitch = (you didn't choose a team, so we assumed you wouldn't mind) + +match.core.damageOwn = You may not damage your own core. + +match.destroyable.damageOwn = You may not damage your own objective. +match.destroyable.repairOther = You may not repair an enemy objective. +match.destroyable.repairDisabled = This objective may not be repaired. + +player.inventoryPreview.noPotionEffects = No potion effects +player.inventoryPreview.notViewable = Player's inventory is not currently viewable + +# {0} = the team that may place the wool +# {1} = the wool +match.wool.placeOther = Only a member of {0} may place {1} here! +# {0} = the correct wool +match.wool.placeWrong = Only {0} may be placed here! +# {0} = the wool +match.wool.craftDisabled = You may not craft {0}. + +match.flag.cannotBreak = The flag cannot be broken +match.flag.cannotBreakBlockUnder = The block under the flag cannot be broken + +match.bed.disabled = Beds are disabled on this map! + +match.lane.enderPearl.disabled = You may not use Ender Pearls to leave your lane +match.lane.reEntry.disabled = You may not re-enter your lane + +match.ghostSquadron.landmine.invalidLocation = You can't place a landmine there! +match.ghostSquadron.landmine.alreadyExists = A landmine already exists there! +match.ghostSquadron.landmine.tooClose = You can't place landmines that close to one another! + +match.portal.protectMessage = You may not obstruct this area + +tutorial.teleport.unsafe = WARNING: Unable to safely teleport you to tutorial spot! + +defuse.water = You may not defuse TNT in water. +defuse.enemy = You may not defuse enemy TNT. + +noPermissions = You do not have permission. +invalidInput.string.numberExpected = Number expected in place of '{0}' + +incorrectWorld.kickMessage = Uh oh! We had an error. Please try rejoining. + +serverFull = Server is full + +autoJoin.teamsFull = Teams are full +autoJoin.capacity = Teams are at capacity +autoJoin.matchFull = Match is full + +match.invalid = Invalid match +match.noCombatLog = You are in too much danger to leave the match right now +match.enderChestsDisabled = Ender chests are disabled on this server +match.playableArea.blockInteractWarning = You may not interact with blocks outside the playing field +match.playableArea.leaveAreaWarning = You may not leave the playing field + +# {0} = Maximum build height in meters +match.maxBuildHeightWarning = You have reached the maximum build height ({0} blocks) + +command.rate.invalidRating = Rating must be between {0} and {1}, inclusive. +command.ratingsDisabled = Map ratings are not enabled on this server. +rating.lowParticipation = Sorry, you haven't participated in this match enough to rate the map +rating.whilePlaying = To rate the map while playing, type {0} +rating.sameRating = You have already rated this map a {0}. + +huddle.globalChatDisabled = Global chat is disabled during team huddle \ No newline at end of file diff --git a/Commons/core/src/main/i18n/templates/pgm/PGMMessages.properties b/Commons/core/src/main/i18n/templates/pgm/PGMMessages.properties new file mode 100644 index 0000000..655749f --- /dev/null +++ b/Commons/core/src/main/i18n/templates/pgm/PGMMessages.properties @@ -0,0 +1,226 @@ +# {0} = map title +command.admin.set.success = Next map set to {0} + +# {0} = map title and version +command.admin.mapreload.success = {0} successfully marked for reloading. +command.admin.mapreload.successAll = All maps successfully marked for reloading. + +command.admin.cancel.success = All countdowns cancelled. + +command.admin.autoStartEnabled = Auto-start enabled for this match +command.admin.autoStartDisabled = Auto-start disabled for this match + +# {0} = map title and version +command.admin.skip.success = Successfully skipped over {0} +# {0} = singular / plural substitution +# {1} = map title and version +command.admin.skip.successMultiple = Successfully skipped {0} to {1} + +# {0} = map title and version +command.admin.skipto.success = Successfully skipped to {0} + +command.admin.pgm = Configuration successfully reloaded. + +# {0} = map title and version +command.map.next.success = Next map: {0} + +command.map.update.running = Not updated, map modified by active match + +command.map.update.success = Successfully updated map + +command.map.update.backupFailed = Failed to backup regions + +# {0} = file name +command.map.update.deleteFailed = Failed to delete {0} + +# {0} = rotation name +command.rotation.set.success = Current rotation set to {0} + +command.rotation.reload.success = Successfully reloaded the rotation + +# {0} = map title +command.rotation.append.success = Successfully appended {0} to the rotation + +# {0} = map title +# {1} = index +command.rotation.insert.success = {0} successfully inserted at index {1} + +# {0} = map title +command.rotation.remove.success = Successfully removed all instances of {0} from the rotation + +# {0} = index +command.rotation.removeat.success = Successfully removed map at index {0} from the rotation + +# {0} = user being forced +# {1} = team being forced onto +command.team.force.success = {0} successfully forced onto {1} + +command.team.shuffle.success = Teams successfully shuffled. + +# {0} = initial name +# {1} = new name +command.team.alias.success = {0} successfully renamed to {1} + +command.chat.team.switchSuccess = Changed default channel to team chat. +command.chat.team.success = Message sent. + +# {0} = team name +command.gameplay.myteam.message = You are on {0} + +# {0} = the class +command.class.select.confirm = You have selected {0} +command.class.select.nextSpawn = Changes will take effect on next spawn. +command.class.view.currentClass = Current class: +command.class.view.list = List all classes by typing '/classes' + +command.development.clearErrors.success = Errors cleared! +command.development.listErrors.noErrors = No errors! + +command.loadNewMaps.loading = Scanning for new maps... +# {0} = found map +command.loadNewMaps.foundSingleMap = Found new map {0} +# {0} = found amount +command.loadNewMaps.foundMultipleMaps = Found {0} new maps +command.loadNewMaps.noMaps = No maps found at all +command.loadNewMaps.noNewMaps = No new maps found + +# {0} = mutation name(s) +command.mutation.enable.later.singular = You have enabled the {0} mutation for the next match +command.mutation.enable.later.plural = You have enabled the {0} mutations for the next match +command.mutation.disable.later.singular = You have disabled the {0} mutation for the next match +command.mutation.disable.later.plural = You have disabled the {0} mutations for the next match + +command.mutation.error.find = Unable to find mutation named '{0}' +command.mutation.error.enabled = All mutations have already been enabled +command.mutation.error.disabled = All mutations have already been disabled + +command.mutation.list.current = Current Mutations +command.mutation.list.queued = Queued Mutations + +# {0} = team name +ffa.join = You joined the match +team.join = You joined {0} +team.balanceWarning = Teams will be auto-balanced if {0} remains stacked + +team.join.deferred.request = You will be assigned to a team at match start +team.join.deferred.cancel = Your request to join the match has been cancelled +team.join.forfeitWarning = {0}: This is a ranked match, and your team is depending on you to {1}! \ + Abandoning your team will earn you a {2}, and possible {3} from ranked play. +team.join.forfeitWarning.emphasis.warning = WARNING +team.join.forfeitWarning.emphasis.playUntilTheEnd = play until the end +team.join.forfeitWarning.emphasis.doubleLoss = double loss +team.join.forfeitWarning.emphasis.suspension = suspension + +team.join.forfeitWarning.timeLimit = If you cannot spend the next {0} playing this match, type {1} now +team.join.forfeitWarning.noTimeLimit = If you cannot commit to playing this entire match, type {0} now + +# {0} = number of players that must join before the match can start +# {1} = team name +start.needMorePlayers.ffa.singular = Waiting for {0} more player to join +start.needMorePlayers.ffa.plural = Waiting for {0} more players to join +start.needMorePlayers.team.singular = Waiting for {0} more player to join {1} +start.needMorePlayers.team.plural = Waiting for {0} more players to join {1} + +defuse.world = You defused world TNT. +# {0} = the player(s) +defuse.player = You defused {0}'s TNT. + +ghostSquadron.landminePlanted = Landmine planted! + +command.rate.successful = Your rating of {0} for {1} {2} has been saved. +command.rate.update = You changed your rating for {1} {2} from {3} to {0}. +rating.changeLater = You can change your rating later using the hopper, or by typing {0} + +# {0} = team name +# {1}/{2} = score: 1 - 5 +rating.create.notify = Someone on {0} rated this map {1} +rating.update.notify = Someone on {0} re-rated this map from {2} to {1} + +# {0}/{1} = score: 1 - 5 +rating.create.notify.ffa = Someone rated this map {0} +rating.update.notify.ffa = Someone re-rated this map from {1} to {0} + +shop.plug.neverKicked = Premium users are never kicked off teams! +shop.plug.neverSwitched = Premium users are never forced to switch teams! +shop.plug.joinFull = Premium users can join full teams! +shop.plug.chooseTeam = Premium users can choose their team! +shop.plug.ffa.neverKicked = Premium users are never kicked from the match! +shop.plug.ffa.joinFull = Premium users can join full matches! +shop.plug.rankedMatches.unlimited = Premium users can play unlimited ranked matches! +shop.plug.rankedMatches.uniform = Premium users can play {0} ranked matches per day! +shop.plug.rankedMatches.upto = Premium users can play up to {0} ranked matches per day! + +# {0} = one of engagement.result.* +engagement.matchRecorded = This match was recorded as a {0} for you +engagement.result.win = win +engagement.result.loss = loss +engagement.result.tie = tie +engagement.result.forfeit = forfeit + +# Note: moved to join module +engagement.committed = You must finish this match before joining other servers + +engagement.forfeitReason.continuousAbsence = You left the match for longer than {0} +engagement.forfeitReason.cumulativeAbsence = You missed more than {0} of the match +engagement.forfeitReason.participationPercent = You participated in less than {0}% of the match +engagement.forfeitReason.missedPart = You missed part of the match + +engagement.statsLink = View your ranked stats at {0} + +matchQuota.matchCounts = You have played {0} of your {1} ranked matches for today +matchQuota.outOfMatches = You cannot play any more ranked matches today +matchQuota.nextMatch = You can play your next ranked match in {0} + +skillRequirement.fail.kills = You need {0} more enemy kills before you can join ranked matches +skillRequirement.fail.general = Play on unranked servers to improve your skill and meet the requirements + +huddle.instructions = Your team now has {0} to strategize before the match starts + +mutation.type.blitz = Blitz +mutation.type.uhc = UHC +mutation.type.explosives = Explosives +mutation.type.no_fall = No Fall +mutation.type.mobs = Mobs +mutation.type.strength = Strength +mutation.type.double_jump = Double Jump +mutation.type.invisibility = Invisibility +mutation.type.lightning = Lightning +mutation.type.rage = Rage +mutation.type.elytra = Elytra + +mutation.type.blitz.desc = no respawning +mutation.type.uhc.desc = no natural regeneration +mutation.type.explosives.desc = stronger and more powerful explosions +mutation.type.no_fall.desc = fall all you want +mutation.type.mobs.desc = natural mob spawning +mutation.type.strength.desc = strength potions everywhere +mutation.type.double_jump.desc = super jump powers +mutation.type.invisibility.desc = enemy players cannot be seen +mutation.type.lightning.desc = lightning strikes from the sky +mutation.type.rage.desc = instant kills +mutation.type.elytra.desc = fly around with an elytra + +tnt.license.info.alreadyHas = You have a TNT license. You can surrender your license by typing {0} +tnt.license.info.doesNotHave = You do not have a TNT license. You can request one by typing {0} + +tnt.license.request.success = You have requested a TNT license. If you are helpful to your team, \ + it will soon be granted automatically. +tnt.license.request.alreadyHas = You have already requested a TNT license, please be patient. + +tnt.license.grant.success = You have been granted a TNT license because you helped your team by {0}. Good job! +tnt.license.grant.reason.enemy_kills = killing enemies +tnt.license.grant.reason.objectives = completing an objective + +tnt.license.revoke.cancelled = Your request for a TNT license has been cancelled. +tnt.license.revoke.hasNotRequested = You have not requested for a TNT license yet. +tnt.license.revoke.success = Your TNT license has been revoked {0}. \ + You can earn it back by killing enemies or \ + completing objectives for your team. +tnt.license.revoke.reason.team_kills = due to excessive team killing +tnt.license.revoke.reason.command = since you requested it + +tnt.license.use.restricted = You need a TNT license to use TNT or Redstone on this map. To apply for a license, type {0} + +item.locked = This item cannot be removed from its slot + +stats.hotbar = {0} kills ({1} streak) {2} deaths {3} K/D \ No newline at end of file diff --git a/Commons/core/src/main/i18n/templates/pgm/PGMMiscellaneous.properties b/Commons/core/src/main/i18n/templates/pgm/PGMMiscellaneous.properties new file mode 100644 index 0000000..bcb1150 --- /dev/null +++ b/Commons/core/src/main/i18n/templates/pgm/PGMMiscellaneous.properties @@ -0,0 +1,6 @@ +misc.blocks = {0} blocks + +misc.ownership = {0}'s {1} + +misc.by = by +misc.team = Team diff --git a/Commons/core/src/main/i18n/templates/pgm/PGMUI.properties b/Commons/core/src/main/i18n/templates/pgm/PGMUI.properties new file mode 100644 index 0000000..f53aa6f --- /dev/null +++ b/Commons/core/src/main/i18n/templates/pgm/PGMUI.properties @@ -0,0 +1,332 @@ +command.map.mapList.title = Loaded Maps + +command.map.mapInfo.edition = Edition +command.map.mapInfo.gamemode.singular = Gamemode +command.map.mapInfo.gamemode.plural = Gamemodes +command.map.mapInfo.genre = Genre +command.map.mapInfo.objective = Objective +command.map.mapInfo.authorSingular = Author +command.map.mapInfo.authorPlural = Authors +command.map.mapInfo.contributors = Contributors +command.map.mapInfo.rules = Rules +command.map.mapInfo.playerLimit = Max players +command.map.mapInfo.xml = XML +command.map.mapInfo.sourceCode.tip = View the XML code that controls this map +command.map.mapInfo.proto = Proto +command.map.mapInfo.source = Source +command.map.mapInfo.folder = Folder + +command.map.currentRotation.title = Current Rotation + +command.map.rotationList.title = Loaded Rotations + +map.genre.objectives = Objectives +map.genre.deathmatch = Deathmatch +map.genre.other = Other + +map.edition.standard = Standard +map.edition.ranked = Ranked +map.edition.tournament = Tournament + +map.phase.production = Production +map.phase.development = Development + +map.gamemode.short.tdm = TDM +map.gamemode.short.ctw = CTW +map.gamemode.short.ctf = CTF +map.gamemode.short.dtc = DTC +map.gamemode.short.dtm = DTM +map.gamemode.short.ad = A/D +map.gamemode.short.koth = KoTH +map.gamemode.short.blitz = Blitz +map.gamemode.short.rage = Rage +map.gamemode.short.scorebox = Scorebox +map.gamemode.short.arcade = Arcade +map.gamemode.short.gs = GS +map.gamemode.short.ffa = FFA +map.gamemode.short.mixed = Mixed +map.gamemode.short.skywars = Skywars +map.gamemode.short.survival = SG + +map.gamemode.long.tdm = Team Deathmatch +map.gamemode.long.ctw = Capture the Wool +map.gamemode.long.ctf = Capture the Flag +map.gamemode.long.dtc = Destroy the Core +map.gamemode.long.dtm = Destroy the Monument +map.gamemode.long.ad = Attack/Defend +map.gamemode.long.koth = King of the Hill +map.gamemode.long.blitz = Blitz +map.gamemode.long.rage = Rage +map.gamemode.long.scorebox = Scorebox +map.gamemode.long.arcade = Arcade +map.gamemode.long.gs = Ghost Squadron +map.gamemode.long.ffa = Free-for-all +map.gamemode.long.mixed = Mixed +map.gamemode.long.skywars = Skywars +map.gamemode.long.survival = Survival Games + +command.match.matchInfo.title = Match +command.match.matchInfo.title.tip = View this match on the web +command.match.matchInfo.time = Time +command.match.matchInfo.matchTime = Match Time +command.match.matchInfo.goals = Goals +command.match.matchInfo.players = Players +command.match.matchInfo.observers = Observers +command.match.matchInfo.ranking = Ranking +command.match.matchInfo.ranking.message = The result of this match will affect your win/loss stats and ranking! + +command.class.list.title = Classes + +# {0} = the current page +# {1} = the total number of pages +command.paginatedResult.page = {0} of {1} + +command.development.listErrors.title = XML Errors + +inventory.closeButton = Close + +teleportTool.displayName = Teleport Tool +editWand.displayName = Edit Wand + +# display name of the hotbar item that opens the picker +teamClass.picker.displayName = Team/Class Selection +teamSelection.picker.displayName = Team Selection +class.picker.displayName = Class Selection +ffa.picker.displayName = Join Match +leave.picker.displayName = Leave Match + +# tooltip that shows when you mouse-over the picker hotbar item +teamSelection.picker.tooltip = Join the game! +leave.picker.tooltip = Join the observers + +# title of the inventory window that opens when you click on the hotbar item +# Length limit: 26 characters +teamClass.picker.title = Choose your team/class +teamSelection.picker.title = Pick your team +class.picker.title = Choose your class + +teamSelection.picker.autoJoin.displayName = Auto Join +teamSelection.picker.autoJoin.tooltip = Puts you on the team with the fewest players +teamSelection.picker.capacity = Team is at capacity, you can not join. +teamSelection.picker.clickToJoin = You are able to pick your team, click to join! +teamSelection.picker.clickToRejoin = Click to rejoin your team! +teamSelection.picker.noPermissions = Premium users can pick their teams! +# {0} = the shop URL +teamSelection.picker.shop = Buy premium at {0} + +tutorial.displayName = View Tutorial +# {0} = the map name +tutorial.tooltip = Learn how to play {0}! + +player.inventoryPreview.potionEffects = Potion Effects +player.inventoryPreview.hungerLevel = Hunger level +player.inventoryPreview.healthLevel = Health level +player.inventoryPreview.specialAbilities = Special Abilities +specialAbility.flying = Flying +specialAbility.doubleJump = Double Jump +specialAbility.knockbackResistance = Knockback Resistance ({0}%) +specialAbility.knockbackReduction = Knockback Reduction ({0}%) +specialAbility.walkSpeed = Walking Speed ({0}x) + +maps.singularCompound = 1 map +# {0} = number of maps +maps.pluralCompound = {0} maps + +countdown.singularCompound = {0} second +# {0} = number of seconds +countdown.pluralCompound = {0} seconds + +broadcast.go = Go! +broadcast.matchStart = The match has started! +broadcast.startCancelled = Match start cancelled + +# {0} = number of winners +broadcast.gameOver.multipleWinners = {0} winners! + +# {0} = the winner +broadcast.gameOver.teamWinText = {0} wins! +broadcast.gameOver.teamWinText.plural = {0} win! + +broadcast.gameOver.gameOverText = Game over! +broadcast.gameOver.teamWon = Your team won! +broadcast.gameOver.teamLost = Your team lost + +# {0} = singular / plural substitution +broadcast.score.limitReached = Score limit of {0} reached + +broadcast.serverRestart.kickMsg = Server restarting! + +# {0} = the current map +broadcast.currentlyPlaying = Currently playing {0} + +# {0} = list of authors +broadcast.welcomeMessage.createdBy = Created by {0} +# {0} = list of mutations +broadcast.welcomeMessage.mutations = Mutations: {0} + +objective.credit.player.percentage = {0} ({1}%) +objective.credit.etc = others +objective.credit.many = many, many players +objective.credit.unknown = unknown forces + +# {0} = team +# {1} = objective +# {2} = team +objective.lose = {0} lost {1} +objective.capture = {0} captured {1} +objective.take = {0} took {1} from {2} + +# {0} = player +# {1} = objective +# {2} = team +match.complete.wool = {0} placed {1} for {2} +match.touch.wool.you = You picked up {1} for {2} +match.touch.wool.teamSuffix = {1} picked up by {0} for {2} + +match.complete.core = {2}'s {1} has leaked +match.touch.core.owner = {2}'s {1} has been damaged +match.touch.core.owner.you = You damaged {2}'s {1} +match.touch.core.owner.toucher = {2}'s {1} damaged by {0} +match.touch.core.toucher = {1} damaged by {0} + +match.complete.destroyable = {2}'s {1} destroyed by {0} +match.touch.destroyable.owner = {2}'s {1} has been damaged +match.touch.destroyable.owner.you = You damaged {2}'s {1} +match.touch.destroyable.owner.toucher = {2}'s {1} damaged by {0} +match.touch.destroyable.toucher = {1} damaged by {0} + +# This is NOT just for destroyables +match.touch.destroyable.deferredNotice = You will receive credit when this objective is completed. + +# {0} = flag name +# {1} = player name +match.flag.pickup = {0} picked up by {1} +match.flag.capture = {0} captured by {1} + +# {0} = flag name +match.flag.captureDenied = {0} will be captured when allowed +match.flag.drop = {0} has been dropped +match.flag.return = {0} has been returned +match.flag.respawn = {0} has respawned +match.flag.respawnTogether = {0} will respawn when all flags are captured + +match.flag.pickup.you = You picked up {0} +match.flag.carrying.you = You are carrying {0} +match.flag.capture.you = You captured {0} + +# {0} = flag name +# {1} = seconds until respawn +match.flag.willRespawn = {0} will respawn in {1} seconds + +# {0} flag being captured +# {1} flag preventing capture +match.flag.captureDenied.byFlag = {0} will be captured when {1} is dropped + +# {0} = the player +# {1} = singular / plural substitution +# {2} = the team name +match.score.scorebox.team = {0} scored {1} for {2} +match.score.scorebox.individual = {0} scored {1} +points.singularCompound = {0} point +points.pluralCompound = {0} points + +# {0} = singular / plural substitution +match.blitz.livesRemaining.message = You have {0} remaining. +match.blitz.livesRemaining.singularLives = 1 life +# {0} = number of lives +match.blitz.livesRemaining.pluralLives = {0} lives + +# {0} = time left in match +match.timeRemaining = Time Remaining: {0} + +match.objectiveMode.header = Mode Changes +match.objectiveMode.noModes = No mode changes scheduled + +# {0} = material +match.objectiveMode.name.destroyable = {0} Monument Mode +match.objectiveMode.name.core = {0} Core Mode +match.objectiveMode.name.generic = {0} Objective Mode + +# {0} = objective mode e.g. "Gold Core Mode" +# {1} = time remaining until mode change +match.objectiveMode.countdown = {0} in {1} + +# {0} = time limit +timeLimit.description.generic = match ends after {0} + +# {0} = time limit +# {1} = what happens after the time limit +timeLimit.description.result = {1} after {0} + +# {0} = time limit +# {1} = what happens after the time limit +timeLimit.commandOutput = The time limit is {0} with the result {1} +timeLimit.preMatchWarning = This match has a time limit of {0} +timeLimit.none = There is no time limit +timeLimit.cancelled = Time limit cancelled +timeLimit.matchEndRemaining = The match ended with {0} remaining +timeLimit.maxDays = Time limit cannot exceed {0} days + + +match.lane.exit = You have left your lane + +match.scoreboard.default.title = Match +match.scoreboard.objectives.title = Objectives +match.scoreboard.scores.title = Scores +match.scoreboard.blitz.title = Blitz +match.scoreboard.livesRemaining.title = Lives Remaining +match.scoreboard.playersRemaining.title = Players Remaining +match.scoreboard.rage.title = Blitz: RAGE +match.scoreboard.gs.title = Ghost Squadron + + +tablist.authors.tooMany = type /map for info + +countdown.cycle.message = Cycling to {0} in {1} +countdown.cycle.complete = Cycled to {0} +countdown.matchStart.message = Match starting in {0} +countdown.huddle.message = Team huddle ends in {0} +broadcast.serverRestart.message = Server restarting in {0} + +defuse.displayName = TNT Defuser +defuse.tooltip = Right-click to defuse TNT in a 5-block radius + +# Length limit: 26 characters +rating.rateThisMap = Rate This Map + +rating.choice.terrible = Terrible! +rating.choice.bad = Bad +rating.choice.ok = OK +rating.choice.good = Good +rating.choice.amazing = Amazing! + +death.respawn.unconfirmed = Left click to respawn +death.respawn.unconfirmed.time = Left click to respawn in {0}s +death.respawn.confirmed.time = Respawning in {0}s +death.respawn.confirmed.waiting = You will respawn as soon as possible... +death.respawn.confirmed.waiting.flagDropped = You will respawn when the flag is dropped... + +stamina.label = Stamina +stamina.depleted = Exhausted + +# {0} = one of the stamina.mutator.* strings +stamina.depletedFromMutator = Exhausted from {0} + +stamina.mutator.sneak = sneaking +stamina.mutator.stand = standing +stamina.mutator.walk = walking +stamina.mutator.run = sprinting +stamina.mutator.jump = jumping +stamina.mutator.run-jump = sprint-jumping +stamina.mutator.injury = injuries +stamina.mutator.melee-miss = swinging +stamina.mutator.melee-hit = fighting +stamina.mutator.archery = shooting + +broadcast.allChestsRefilled = All chests have been refilled +broadcast.allPlayersGlowing = All players are now glowing +broadcast.chestsRefillIn3And5 = Chests will refill in 3 minutes and 5 minutes +broadcast.alliancesNotAllowed = Creating alliances with other players is NOT allowed + +countdown.chestsRefill = Chests will refill in {0} +countdown.glowingEffect = Glowing effect in {0} diff --git a/Commons/core/src/main/i18n/templates/projectares/PAErrors.properties b/Commons/core/src/main/i18n/templates/projectares/PAErrors.properties new file mode 100644 index 0000000..643e37a --- /dev/null +++ b/Commons/core/src/main/i18n/templates/projectares/PAErrors.properties @@ -0,0 +1,56 @@ +command.register.notice = Registration has moved to the website! +command.register.link = Register your account at {0} + +command.reports.notEnabled = Reports are not enabled. + +# {0} = the exempt player +command.report.exempt = {0} may not be reported +# {0} = singular/plural substitution +command.report.cooldown = You must wait {0} to use /report again + +command.nick.notEnabled = Nicks are not enabled +command.nick.invalidCharacters = Nicknames may only contain letters, numbers, and underscores. +command.nick.tooLong = Nicknames are limited to 16 characters in length. +command.nick.tooShort = Nicknames must be at least 4 characters long. +command.nick.noActiveNicks = Nobody is disguised. +command.nick.nickTaken = The nickname {0} is unusable because it matches a real Minecraft account. +command.nick.invalid = Invalid nickname +command.nick.verifyError = An error occurred trying to verify the nickname {0}. Waiting and trying again might fix this. + +command.freeze.notEnabled = Freeze is not enabled. +# {0} = the exempt player +command.freeze.exempt = {0} may not be frozen + +command.staff.noStaffOnline = No staff online. + +command.pa.reload.failure = Failed to reload the ProjectAres configuration. Is the plugin loaded? + +command.friends.notEnabled = Friends are not enabled on this server. +command.friends.none = You don't have any friends :( + +# {0} = the player's name +command.find.noSession = {0} is only a ghost + +nick.joinVerifyError = You are not disguised right now because an error occurred trying to verify your nickname with the Minecraft servers. Reconnecting may fix this. +nick.joinAccountConflict = You are not disguised right now because your nickname {0} matches a real Minecraft account. Choose a new nickname by typing /nick and reconnect to the server. + +command.specifyPlayer = Please specify a player +command.multiplePlayersFound = More than one player found! Be more specific. +command.multipleOnlinePlayersFound = More than one player found! Use @ for exact matching. +command.playerNotFound = No players matched query. +command.serverNotFound = No servers matched query. +command.playerNotOnline = {0} is not currently online +command.playerLocationUnavailable = {0}'s current location is unavailable + +# {0} = the name of the setting +command.database.edit.unknownSetting = Unknown setting '{0}' + +command.database.reconnect.unknownError = Error connecting to database. See console for details. + +command.database.disconnect.notConnected = The database is not connected. + +command.reply.noMessages = You have no private messages to reply to + +command.message.blockedNoPermissions = Your message was not sent because {0} has chosen not to receive private messages. + +noPermissions = You do not have permission. diff --git a/Commons/core/src/main/i18n/templates/projectares/PAMessages.properties b/Commons/core/src/main/i18n/templates/projectares/PAMessages.properties new file mode 100644 index 0000000..21fcef0 --- /dev/null +++ b/Commons/core/src/main/i18n/templates/projectares/PAMessages.properties @@ -0,0 +1,67 @@ +command.database.info.connectedState.connected = Database is connected +command.database.info.connectedState.disconnected = Database is disconnected + +command.database.edit.address = Address set to {0} +command.database.edit.database = Database set to {0} +command.database.edit.auth = Auth set to {0} +command.database.edit.username = Username set to {0} +command.database.edit.password = Password set to {0} +command.database.edit.connectionCount = Connection count set to {0} + +command.database.reload = Reloaded the database configuration. + +command.database.reconnect.success = Successfully reconnected to the database. + +command.database.disconnect.success = Successfully disconnected from the database. + +# {0} = the player +# {1} = the localized time +# {2} = the server +command.lastSeen.unknown = {0} has not been seen for a long time +command.lastSeen.offline.server = {0} seen {1} ago on {2} +command.lastSeen.offline.noServer = {0} seen {1} ago +# {1} = command.lastSeen.online (used for formatting) +command.lastSeen.online.server = {0} is {1} on {2} +command.lastSeen.online.noServer = {0} is {1} +command.lastSeen.online = online + +command.report.successful.dealtWithMessage = The issue will be dealt with shortly. + +command.nick.checkingNickname = Checking nickname {0}... +command.nick.clearSelf.immediate = Your nickname has been cleared. You are not disguised. +command.nick.clearSelf.queued = Your nickname will be cleared the next time you connect to the server. +command.nick.clearOther.immediate = {0}'s nickname has been cleared. +command.nick.clearOther.queued = {0}'s nickname will be cleared the next time they connect to the server. +command.nick.setSelf.immediate = You are disguised as {0} +command.nick.setOther.immediate = {1} is disguised as {0} +command.nick.setSelf.queued = You will be disguised as {0} the next time you connect to the server. +command.nick.setOther.queued = {1} will be disguised as {0} the next time they connect to the server. + +# {0} = the player +command.freeze.frozen = You have frozen {0} +command.freeze.unfrozen = You have unfrozen {0} + +# {0} = the server name +command.server.teleporting = Teleporting you to {0} +command.server.currentServer = You are currently on {0} +command.server.switchPrompt = To change servers, type /server [name] + +command.pa.reload.success = Reloaded the ProjectAres configuration + +command.lookup.cleanRecord = {0} has a clean record + +# {0} = the freezer +freeze.frozen = You have been frozen by {0} +freeze.unfrozen = You have been unfrozen by {0} +freeze.itemName = Player Freezer +freeze.itemDescription = Right-click a player to freeze/thaw them + +nick.joinReminder = You are currently disguised. Use {0} to see your nickname or {1} to remove it. + +# {0} = the player +broadcast.joinMessage = {0} joined +broadcast.leaveMessage = {0} left +broadcast.changeServerMessage = {0} changed servers + +broadcast.warnMessage.manual = {0} warned {1} for {2} +broadcast.warnMessage.automatic = {0} automatically warned {1} for {2} diff --git a/Commons/core/src/main/i18n/templates/projectares/PAUI.properties b/Commons/core/src/main/i18n/templates/projectares/PAUI.properties new file mode 100644 index 0000000..5ade462 --- /dev/null +++ b/Commons/core/src/main/i18n/templates/projectares/PAUI.properties @@ -0,0 +1,56 @@ +command.database.queueInfo.title = Project Ares Queue Info +command.database.queueInfo.queueSize = Queue size: +command.database.queueInfo.timeRunning = Time running: +command.database.queueInfo.commandsProcessed = Commands processed: +command.database.queueInfo.commandsPerSecond = Commands / second: + +command.database.info.serverID = Server ID: +command.database.info.addresses.singular = Address: +command.database.info.addresses.plural = Addresses: +command.database.info.database = Database: +command.database.info.username = Username: +command.database.info.password = Password: +command.database.info.connections = Connection count: + +command.friends.title = Your Friends + +command.reports.serverTitle = Recent Server Reports +command.reports.networkTitle = Recent Network Reports + +command.servers.title = Servers +command.servers.online = Online: +command.servers.currentMap = Current Map: + +command.trophies.self.title = Your Trophies +command.trophies.other.title = {0}'s Trophies + +command.staff.title = Online Staff + +command.lookup.title = Record for {0} + +warningDisplay.title = WARNING + +actionString.warn = Warned +actionString.kick = Kicked +actionString.ban.permanent = Permanent Ban +actionString.ban.temporary = {0} Day Ban + +appealNotification.title = Appeals Notification + +# {0} = the reporter +# {1} = the player +report.message = {0} reports {1}: + +kick.banMessage.banned.temporary = Banned +kick.banMessage.banned.permanent = Permanently Banned +# {0} = the localized time +kick.banMessage.expiry = Expires {0} +# {0} = the appeal URL +kick.banMessage.appealURL = Visit {0} to appeal + +punishment.noneIssued = You have never issued any punishments + +# {0} = the page +# {1} = the total pages +pageHeader = Page {0} of {1} +currentPage = Page {0} diff --git a/Commons/core/src/main/i18n/templates/raindrops/RaindropsMessages.properties b/Commons/core/src/main/i18n/templates/raindrops/RaindropsMessages.properties new file mode 100644 index 0000000..2456990 --- /dev/null +++ b/Commons/core/src/main/i18n/templates/raindrops/RaindropsMessages.properties @@ -0,0 +1,22 @@ +# {0} the colored team name +matchend.team.won = your team ({0}) won +matchend.team.tied = your team ({0}) tied +matchend.team.won.singleton = your team won + +# {0} the colored team name +# {1} the number of minutes you were loyal to that team +matchend.team.loyalty = you were loyal to your team ({0}) for {1} minutes + +# {0} the goal completion message +match.goal.touch = you {0} + +# {0} the colored name of the wool +match.wool.place = you placed {0} +match.wool.destroy = you destroyed {0} + +# {0} the percentage destroyed of the destroyable +# {1} the name of the destroyable +match.destroyable.destroy = you destroyed {0}% of {1} + +# {0} the name of the person that was killed +match.kill.killed = killed {0} diff --git a/Commons/core/src/main/i18n/templates/tourney/Tourney.properties b/Commons/core/src/main/i18n/templates/tourney/Tourney.properties new file mode 100644 index 0000000..1aebb66 --- /dev/null +++ b/Commons/core/src/main/i18n/templates/tourney/Tourney.properties @@ -0,0 +1,21 @@ +# {0} = Player count +tourney.kick.maxPlayers = Your team already has {0} players present + +# {0} = Match ID +# {1} = Map name +# {2} = List of teams +tourney.recordedMatch = Recorded match {0} on map {1} for teams {2} + +# {0} = Tournament name +# {1} = Tournament team name +# {2} = Map team name +tourney.teams.title = Teams in {0} +tourney.team.roster.title = Roster for team {1} +tourney.team.notSpecified = No team specified +tourney.team.notFound = No team named "{1}" +tourney.team.notOnAnyTeam = You are not a member of any team +tourney.team.cannotRegister = Team cannot be registered at this time +tourney.team.cannotUnregister = Team cannot be unregistered at this time +tourney.team.alreadyRegistered = Team {1} is already registered to play as {2} +tourney.team.registered = Team {1} registered to play as {2} +tourney.team.unregistered = Team {1} unregistered from match diff --git a/Commons/core/src/main/java/tc/oc/analytics/AnalyticsClient.java b/Commons/core/src/main/java/tc/oc/analytics/AnalyticsClient.java new file mode 100644 index 0000000..f926b53 --- /dev/null +++ b/Commons/core/src/main/java/tc/oc/analytics/AnalyticsClient.java @@ -0,0 +1,14 @@ +package tc.oc.analytics; + +import tc.oc.minecraft.api.event.Activatable; + +public interface AnalyticsClient extends Activatable { + + void count(String metric, int quantity); + + void measure(String metric, double value); + + void sample(String metric, double value); + + void event(Event event); +} diff --git a/Commons/core/src/main/java/tc/oc/analytics/AnalyticsManifest.java b/Commons/core/src/main/java/tc/oc/analytics/AnalyticsManifest.java new file mode 100644 index 0000000..9d436d8 --- /dev/null +++ b/Commons/core/src/main/java/tc/oc/analytics/AnalyticsManifest.java @@ -0,0 +1,19 @@ +package tc.oc.analytics; + +import tc.oc.commons.core.inject.HybridManifest; + +public class AnalyticsManifest extends HybridManifest { + + @Override + protected void configure() { + installFactory(MetricFactory.class); + bind(DynamicTagger.class); + + expose(MetricFactory.class); + expose(DynamicTagger.class); + expose(AnalyticsClient.class); + + final TaggerBinder taggers = new TaggerBinder(publicBinder()); + taggers.addBinding().to(StageTagger.class); + } +} diff --git a/Commons/core/src/main/java/tc/oc/analytics/Count.java b/Commons/core/src/main/java/tc/oc/analytics/Count.java new file mode 100644 index 0000000..35ce882 --- /dev/null +++ b/Commons/core/src/main/java/tc/oc/analytics/Count.java @@ -0,0 +1,32 @@ +package tc.oc.analytics; + +import javax.inject.Inject; + +import com.google.inject.assistedinject.Assisted; + +/** + * Number of occurances of some momentary event, + * or a quantity associated with those events. + */ +public class Count extends Metric { + + @Inject Count(@Assisted String name) { + super(name); + } + + public void increment(int quantity) { + driver.count(name(), quantity); + } + + public void decrement(int quantity) { + increment(-quantity); + } + + public void increment() { + increment(1); + } + + public void decrement() { + decrement(1); + } +} diff --git a/Commons/core/src/main/java/tc/oc/analytics/Distribution.java b/Commons/core/src/main/java/tc/oc/analytics/Distribution.java new file mode 100644 index 0000000..fac048a --- /dev/null +++ b/Commons/core/src/main/java/tc/oc/analytics/Distribution.java @@ -0,0 +1,19 @@ +package tc.oc.analytics; + +import javax.inject.Inject; + +import com.google.inject.assistedinject.Assisted; + +/** + * Number associated with distinct samples (generates a histogram) + */ +public class Distribution extends Metric { + + @Inject Distribution(@Assisted String name) { + super(name); + } + + public void sample(double value) { + driver.sample(name(), value); + } +} diff --git a/Commons/core/src/main/java/tc/oc/analytics/DynamicTagger.java b/Commons/core/src/main/java/tc/oc/analytics/DynamicTagger.java new file mode 100644 index 0000000..aeb0e7b --- /dev/null +++ b/Commons/core/src/main/java/tc/oc/analytics/DynamicTagger.java @@ -0,0 +1,56 @@ +package tc.oc.analytics; + +import java.util.Set; +import javax.inject.Singleton; + +import com.google.common.collect.ImmutableSet; +import tc.oc.commons.core.util.Threadable; +import tc.oc.commons.core.util.ThrowingRunnable; +import tc.oc.commons.core.util.ThrowingSupplier; + +@Singleton +public class DynamicTagger implements Tagger { + + private final Threadable> current = new Threadable<>(ImmutableSet::of); + + @Override + public ImmutableSet tags() { + return current.need(); + } + + public void withTags(Set tags, ThrowingRunnable block) throws E { + current.let( + ImmutableSet.builder() + .addAll(current.need()) + .addAll(tags) + .build(), + block + ); + } + + public U withTags(Set tags, ThrowingSupplier block) throws E { + return current.let( + ImmutableSet.builder() + .addAll(current.need()) + .addAll(tags) + .build(), + block + ); + } + + public void withTag(Tag tag, ThrowingRunnable block) throws E { + withTags(ImmutableSet.of(tag), block); + } + + public U withTag(Tag tag, ThrowingSupplier block) throws E { + return withTags(ImmutableSet.of(tag), block); + } + + public void withTag(String name, String value, ThrowingRunnable block) throws E { + withTag(Tag.of(name, value), block); + } + + public U withTag(String name, String value, ThrowingSupplier block) throws E { + return withTag(Tag.of(name, value), block); + } +} diff --git a/Commons/core/src/main/java/tc/oc/analytics/Event.java b/Commons/core/src/main/java/tc/oc/analytics/Event.java new file mode 100644 index 0000000..a413ac1 --- /dev/null +++ b/Commons/core/src/main/java/tc/oc/analytics/Event.java @@ -0,0 +1,72 @@ +package tc.oc.analytics; + +public interface Event { + enum Level { + SUCCESS, INFO, WARNING, ERROR + } + + Level level(); + String key(); + String title(); + String body(); + + default Event withBody(String body) { + return new EventImpl(level(), key(), title(), body); + } + + static Event of(Level level, String key, String title, String body) { + return new EventImpl(level, key, title, body); + } + + static Event of(Level level, String key, String title) { + return new EventImpl(level, key, title, ""); + } + + static Event success(String key, String title) { + return of(Level.SUCCESS, key, title); + } + + static Event info(String key, String title) { + return of(Level.INFO, key, title); + } + + static Event warning(String key, String title) { + return of(Level.WARNING, key, title); + } + + static Event error(String key, String title) { + return of(Level.ERROR, key, title); + } +} + +class EventImpl implements Event { + private final Level level; + private final String key, title, body; + + EventImpl(Level level, String key, String title, String body) { + this.level = level; + this.key = key; + this.title = title; + this.body = body; + } + + @Override + public Level level() { + return level; + } + + @Override + public String key() { + return key; + } + + @Override + public String title() { + return title; + } + + @Override + public String body() { + return body; + } +} diff --git a/Commons/core/src/main/java/tc/oc/analytics/Gauge.java b/Commons/core/src/main/java/tc/oc/analytics/Gauge.java new file mode 100644 index 0000000..f39b4f4 --- /dev/null +++ b/Commons/core/src/main/java/tc/oc/analytics/Gauge.java @@ -0,0 +1,19 @@ +package tc.oc.analytics; + +import javax.inject.Inject; + +import com.google.inject.assistedinject.Assisted; + +/** + * Number that varies over time, measured at arbitrary times. + */ +public class Gauge extends Metric { + + @Inject Gauge(@Assisted String name) { + super(name); + } + + public void measure(double value) { + driver.measure(name(), value); + } +} diff --git a/Commons/core/src/main/java/tc/oc/analytics/Metric.java b/Commons/core/src/main/java/tc/oc/analytics/Metric.java new file mode 100644 index 0000000..f509456 --- /dev/null +++ b/Commons/core/src/main/java/tc/oc/analytics/Metric.java @@ -0,0 +1,18 @@ +package tc.oc.analytics; + +import javax.inject.Inject; + +public abstract class Metric { + + @Inject protected AnalyticsClient driver; + + private final String name; + + Metric(String name) { + this.name = name; + } + + public String name() { + return name; + } +} diff --git a/Commons/core/src/main/java/tc/oc/analytics/MetricFactory.java b/Commons/core/src/main/java/tc/oc/analytics/MetricFactory.java new file mode 100644 index 0000000..98c362e --- /dev/null +++ b/Commons/core/src/main/java/tc/oc/analytics/MetricFactory.java @@ -0,0 +1,10 @@ +package tc.oc.analytics; + +public interface MetricFactory { + + Count count(String name); + + Gauge gauge(String name); + + Distribution distribution(String name); +} diff --git a/Commons/core/src/main/java/tc/oc/analytics/StageTagger.java b/Commons/core/src/main/java/tc/oc/analytics/StageTagger.java new file mode 100644 index 0000000..5d69786 --- /dev/null +++ b/Commons/core/src/main/java/tc/oc/analytics/StageTagger.java @@ -0,0 +1,22 @@ +package tc.oc.analytics; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import com.google.common.collect.ImmutableSet; +import com.google.inject.Stage; + +@Singleton +public class StageTagger implements Tagger { + + private final ImmutableSet tags; + + @Inject StageTagger(Stage stage) { + this.tags = ImmutableSet.of(Tag.of("environment", stage.name().toLowerCase())); + } + + @Override + public ImmutableSet tags() { + return tags; + } +} diff --git a/Commons/core/src/main/java/tc/oc/analytics/Tag.java b/Commons/core/src/main/java/tc/oc/analytics/Tag.java new file mode 100644 index 0000000..331275b --- /dev/null +++ b/Commons/core/src/main/java/tc/oc/analytics/Tag.java @@ -0,0 +1,40 @@ +package tc.oc.analytics; + +import java.util.Objects; + +import tc.oc.commons.core.util.Utils; + +public final class Tag { + + private final String name, value; + + private Tag(String name, String value) { + this.name = name; + this.value = value; + } + + public static Tag of(String name, String value) { + return new Tag(name, value); + } + + public String name() { + return name; + } + + public String value() { + return value; + } + + @Override + public int hashCode() { + return Objects.hash(name, value); + } + + @Override + public boolean equals(Object obj) { + return Utils.equals(Tag.class, this, obj, that -> + this.name().equals(that.name()) && + this.value().equals(that.value()) + ); + } +} diff --git a/Commons/core/src/main/java/tc/oc/analytics/TagSetBuilder.java b/Commons/core/src/main/java/tc/oc/analytics/TagSetBuilder.java new file mode 100644 index 0000000..5d7bf9e --- /dev/null +++ b/Commons/core/src/main/java/tc/oc/analytics/TagSetBuilder.java @@ -0,0 +1,43 @@ +package tc.oc.analytics; + +import java.util.Iterator; + +import com.google.common.collect.ImmutableSet; + +public class TagSetBuilder { + + private final ImmutableSet.Builder builder = ImmutableSet.builder(); + + public ImmutableSet build() { + return builder.build(); + } + + public TagSetBuilder add(Tag tag) { + builder.add(tag); + return this; + } + + public TagSetBuilder add(Tag... tags) { + builder.add(tags); + return this; + } + + public TagSetBuilder addAll(Iterable tags) { + builder.addAll(tags); + return this; + } + + public TagSetBuilder addAll(Iterator tags) { + builder.addAll(tags); + return this; + } + + public TagSetBuilder add(String name, String value) { + return add(Tag.of(name, value)); + } + + public TagSetBuilder addAll(String name, Iterable values) { + values.forEach(value -> add(name, value)); + return this; + } +} diff --git a/Commons/core/src/main/java/tc/oc/analytics/Tagger.java b/Commons/core/src/main/java/tc/oc/analytics/Tagger.java new file mode 100644 index 0000000..f1d0d22 --- /dev/null +++ b/Commons/core/src/main/java/tc/oc/analytics/Tagger.java @@ -0,0 +1,7 @@ +package tc.oc.analytics; + +import com.google.common.collect.ImmutableSet; + +public interface Tagger { + ImmutableSet tags(); +} diff --git a/Commons/core/src/main/java/tc/oc/analytics/TaggerBinder.java b/Commons/core/src/main/java/tc/oc/analytics/TaggerBinder.java new file mode 100644 index 0000000..d978eb4 --- /dev/null +++ b/Commons/core/src/main/java/tc/oc/analytics/TaggerBinder.java @@ -0,0 +1,18 @@ +package tc.oc.analytics; + +import com.google.inject.Binder; +import com.google.inject.binder.LinkedBindingBuilder; +import com.google.inject.multibindings.Multibinder; + +public class TaggerBinder { + + private final Multibinder taggers; + + public TaggerBinder(Binder binder) { + this.taggers = Multibinder.newSetBinder(binder, Tagger.class); + } + + public LinkedBindingBuilder addBinding() { + return taggers.addBinding(); + } +} diff --git a/Commons/core/src/main/java/tc/oc/analytics/datadog/DataDogClient.java b/Commons/core/src/main/java/tc/oc/analytics/datadog/DataDogClient.java new file mode 100644 index 0000000..8fefa6e --- /dev/null +++ b/Commons/core/src/main/java/tc/oc/analytics/datadog/DataDogClient.java @@ -0,0 +1,135 @@ +package tc.oc.analytics.datadog; + +import java.util.Collection; +import java.util.logging.Logger; +import java.util.stream.Collectors; +import javax.annotation.Nullable; +import javax.inject.Inject; +import javax.inject.Provider; + +import com.google.common.cache.LoadingCache; +import com.google.common.collect.ImmutableSet; +import com.google.inject.OutOfScopeException; +import com.timgroup.statsd.StatsDClient; +import tc.oc.analytics.AnalyticsClient; +import tc.oc.analytics.Event; +import tc.oc.analytics.Tag; +import tc.oc.analytics.Tagger; +import tc.oc.commons.core.inject.Injection; +import tc.oc.commons.core.logging.Loggers; +import tc.oc.commons.core.util.CacheUtils; +import tc.oc.minecraft.suspend.Suspendable; + +class DataDogClient implements AnalyticsClient, Suspendable { + + private final Logger logger; + private final DataDogConfig config; + private final Provider clientProvider; + + private @Nullable StatsDClient client; + + // Provision taggers at the moment the tags are used, so they can be scoped. + // We also use a provider for the entire collection to avoid circular deps. + private final Provider>> taggers; + + private final LoadingCache tagCache = CacheUtils.newWeakKeyCache( + tag -> tag.name() + ":" + tag.value() + ); + + private final LoadingCache, String> tagSetCache = CacheUtils.newWeakKeyCache( + tags -> tags.stream() + .map(tagCache::getUnchecked) + .collect(Collectors.joining(",")) + ); + + @Inject DataDogClient(Loggers loggers, DataDogConfig config, Provider clientProvider, Provider>> taggers) { + this.logger = loggers.get(getClass()); + this.config = config; + this.clientProvider = clientProvider; + this.taggers = taggers; + + this.client = clientProvider.get(); + } + + @Override + public boolean isActive() { + return config.enabled(); + } + + private static final String[] EMPTY = new String[]{}; + + String[] renderedTags() { + final StringBuilder sb = new StringBuilder(); + boolean some = false; + for(Provider provider : taggers.get()) { + final Tagger tagger; + try { + tagger = Injection.unwrappingExceptions(OutOfScopeException.class, provider); + } catch(OutOfScopeException e) { + // If the tagger is out of scope, just omit its tags, + // but log a warning in case this hides an unexpected exception. + logger.warning("Ignoring out-of-scope tagger (" + e.toString() + ")"); + continue; + } + + final ImmutableSet tags = tagger.tags(); + if(!tags.isEmpty()) { + if(some) sb.append(','); + some = true; + sb.append(tagSetCache.getUnchecked(tags)); + } + } + return some ? new String[] {sb.toString()} : EMPTY; + } + + @Override + public void count(String metric, int quantity) { + if(client == null) return; + client.count(metric, quantity, renderedTags()); + } + + @Override + public void measure(String metric, double value) { + if(client == null) return; + client.gauge(metric, value, renderedTags()); + } + + @Override + public void sample(String metric, double value) { + if(client == null) return; + client.histogram(metric, value, renderedTags()); + } + + @Override + public void event(Event event) { + if(client == null) return; + client.recordEvent( + com.timgroup.statsd.Event.builder() + .withAlertType(alertType(event.level())) + .withAggregationKey(event.key()) + .withTitle(event.title()) + .withText(event.body()) + .build() + ); + } + + private static com.timgroup.statsd.Event.AlertType alertType(Event.Level level) { + switch(level) { + case SUCCESS: return com.timgroup.statsd.Event.AlertType.SUCCESS; + case WARNING: return com.timgroup.statsd.Event.AlertType.WARNING; + case ERROR: return com.timgroup.statsd.Event.AlertType.ERROR; + default: return com.timgroup.statsd.Event.AlertType.INFO; + } + } + + @Override + public void suspend() { + client.stop(); + client = null; + } + + @Override + public void resume() { + client = clientProvider.get(); + } +} diff --git a/Commons/core/src/main/java/tc/oc/analytics/datadog/DataDogConfig.java b/Commons/core/src/main/java/tc/oc/analytics/datadog/DataDogConfig.java new file mode 100644 index 0000000..eb41786 --- /dev/null +++ b/Commons/core/src/main/java/tc/oc/analytics/datadog/DataDogConfig.java @@ -0,0 +1,28 @@ +package tc.oc.analytics.datadog; + +import javax.inject.Inject; + +import tc.oc.minecraft.api.configuration.Configuration; +import tc.oc.minecraft.api.configuration.ConfigurationSection; +import tc.oc.minecraft.api.configuration.InvalidConfigurationException; + +class DataDogConfig { + + private final ConfigurationSection section; + + @Inject DataDogConfig(Configuration config) throws InvalidConfigurationException { + this.section = config.getSection("datadog"); + } + + public boolean enabled() { + return section.getBoolean("enabled", false); + } + + public String host() { + return section.getString("host", "localhost"); + } + + public int port() { + return section.getInt("port", 8125); + } +} diff --git a/Commons/core/src/main/java/tc/oc/analytics/datadog/DataDogManifest.java b/Commons/core/src/main/java/tc/oc/analytics/datadog/DataDogManifest.java new file mode 100644 index 0000000..c5a0283 --- /dev/null +++ b/Commons/core/src/main/java/tc/oc/analytics/datadog/DataDogManifest.java @@ -0,0 +1,34 @@ +package tc.oc.analytics.datadog; + +import javax.inject.Singleton; + +import com.google.inject.Provides; +import com.timgroup.statsd.NoOpStatsDClient; +import com.timgroup.statsd.NonBlockingStatsDClient; +import com.timgroup.statsd.StatsDClient; +import tc.oc.analytics.AnalyticsClient; +import tc.oc.commons.core.inject.HybridManifest; +import tc.oc.minecraft.suspend.SuspendableBinder; + +public class DataDogManifest extends HybridManifest { + + @Override + protected void configure() { + bind(DataDogConfig.class); + + bind(AnalyticsClient.class) + .to(DataDogClient.class); + + bind(DataDogClient.class).in(Singleton.class); + expose(DataDogClient.class); + new SuspendableBinder(publicBinder()) + .addBinding().to(DataDogClient.class); + } + + @Provides + StatsDClient statsDClient(DataDogConfig config) { + return config.enabled() + ? new NonBlockingStatsDClient(null, config.host(), config.port()) + : new NoOpStatsDClient(); + } +} diff --git a/Commons/core/src/main/java/tc/oc/commons/core/CommonsCoreManifest.java b/Commons/core/src/main/java/tc/oc/commons/core/CommonsCoreManifest.java new file mode 100644 index 0000000..4924288 --- /dev/null +++ b/Commons/core/src/main/java/tc/oc/commons/core/CommonsCoreManifest.java @@ -0,0 +1,69 @@ +package tc.oc.commons.core; + +import java.nio.file.Path; +import java.nio.file.Paths; +import javax.inject.Named; +import javax.inject.Singleton; + +import com.google.inject.Provides; +import tc.oc.analytics.AnalyticsManifest; +import tc.oc.analytics.datadog.DataDogManifest; +import tc.oc.commons.core.commands.DebugCommands; +import tc.oc.commons.core.inject.HybridManifest; +import tc.oc.commons.core.inject.Manifest; +import tc.oc.commons.core.localization.LocalizedFileManager; +import tc.oc.commons.core.plugin.PluginFacetBinder; +import tc.oc.commons.core.restart.RestartManager; +import tc.oc.file.PathWatcherService; +import tc.oc.file.PathWatcherServiceImpl; +import tc.oc.minecraft.analytics.MinecraftAnalyticsManifest; +import tc.oc.minecraft.server.ServerFilterManifest; +import tc.oc.minecraft.suspend.SuspendableBinder; + +public class CommonsCoreManifest extends HybridManifest { + @Override + protected void configure() { + publicBinder().install(new PathsManifest()); + publicBinder().install(new ServerFilterManifest()); + + new SuspendableBinder(publicBinder()); // Just bind the Set + + install(new AnalyticsManifest()); + install(new MinecraftAnalyticsManifest()); + install(new DataDogManifest()); + + bindAndExpose(RestartManager.class); + bindAndExpose(LocalizedFileManager.class); + + expose(PathWatcherService.class); + bind(PathWatcherService.class) + .to(PathWatcherServiceImpl.class); + + final PluginFacetBinder facets = new PluginFacetBinder(binder()); + facets.register(DebugCommands.class); + facets.register(RestartManager.class); + facets.register(PathWatcherServiceImpl.class); + } + + class PathsManifest extends Manifest { + @Provides @Singleton + @Named("repositories") Path repositoriesPath() { + return Paths.get("/minecraft/repo"); + } + + @Provides @Singleton + @Named("translations") Path translationsPath() { + return Paths.get("/minecraft/translations"); + } + + @Provides @Singleton + @Named("maps") Path mapsPath(@Named("repositories") Path repos) { + return repos.resolve("maps"); + } + + @Provides @Singleton + @Named("configuration") Path configurationPath(@Named("repositories") Path repos) { + return repos.resolve("Config"); + } + } +} diff --git a/Commons/core/src/main/java/tc/oc/commons/core/commands/DebugCommands.java b/Commons/core/src/main/java/tc/oc/commons/core/commands/DebugCommands.java new file mode 100644 index 0000000..a73f485 --- /dev/null +++ b/Commons/core/src/main/java/tc/oc/commons/core/commands/DebugCommands.java @@ -0,0 +1,117 @@ +package tc.oc.commons.core.commands; + +import java.util.Map; +import java.util.logging.Logger; +import javax.inject.Inject; + +import com.sk89q.minecraft.util.commands.Command; +import com.sk89q.minecraft.util.commands.CommandContext; +import com.sk89q.minecraft.util.commands.CommandException; +import com.sk89q.minecraft.util.commands.CommandPermissions; +import tc.oc.api.docs.virtual.DeployInfo; +import tc.oc.api.minecraft.servers.StartupServerDocument; +import tc.oc.api.util.Permissions; +import tc.oc.commons.core.chat.Component; +import tc.oc.commons.core.scheduler.Scheduler; +import tc.oc.minecraft.api.command.CommandSender; +import tc.oc.minecraft.api.server.LocalServer; +import tc.oc.parse.primitive.DurationParser; + +public class DebugCommands implements Commands { + + private final LocalServer minecraftServer; + private final StartupServerDocument startupDocument; + private final Scheduler scheduler; + private final DurationParser durationParser; + + @Inject DebugCommands(LocalServer minecraftServer, StartupServerDocument startupDocument, Scheduler scheduler, DurationParser durationParser) { + this.minecraftServer = minecraftServer; + this.startupDocument = startupDocument; + this.scheduler = scheduler; + this.durationParser = durationParser; + } + + @Command( + aliases = "sleep", + desc = "Put the main server thread to sleep for the given duration", + usage = ". Aside from this, + * the types of the Filterables may not have any particular relationship. + */ +public interface Filterable extends IMatchQuery { + + /** + * The (single) Filterable that contains this one, or empty if this + * is a top-level object. + */ + Optional> filterableParent(); + + /** + * Return the enclosing Filterable of the given subtype, if any. + * + * This object is returned if it extends the given type. + */ + default > Optional filterableAncestor(Class type) { + return type.isInstance(this) ? Optional.of((R) this) + : filterableParent().flatMap(parent -> parent.filterableAncestor(type)); + } + + /** + * Return all {@link Filterable} objects that this object is directly composed of. + * + * This object is NOT included in the result, nor are indirect components i.e. grandchildren, etc. + */ + Stream> filterableChildren(); + + /** + * Return all individual objects of the given Filterable subtype that this object is composed of, + * directly or indirectly, possibly including this object itself. + * + * This method simply tests this object's type, and recurses on all {@link #filterableChildren()}. + * Subclasses should provide a more efficient implementation, if possible. + */ + default > Stream filterableDescendants(Class type) { + Stream result = filterableChildren().flatMap(child -> child.filterableDescendants(type)); + if(type.isInstance(this)) { + result = Stream.concat(Stream.of((R) this), result); + } + return result; + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/Filterables.java b/PGM/src/main/java/tc/oc/pgm/filters/Filterables.java new file mode 100644 index 0000000..4180822 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/Filterables.java @@ -0,0 +1,32 @@ +package tc.oc.pgm.filters; + +import java.util.List; + +import com.google.common.collect.ImmutableList; +import tc.oc.pgm.match.Match; +import tc.oc.pgm.match.MatchPlayer; +import tc.oc.pgm.match.Party; + +public final class Filterables { + private Filterables() {} + + // Filterables ordered from general to specific + public static final List>> SCOPES = ImmutableList.of( + Match.class, + Party.class, + MatchPlayer.class + ); + + /** + * Return the "scope" of the given filter, which is the most general + * {@link Filterable} type that it responds to. + */ + public static Class> scope(Filter filter) { + for(Class> scope : SCOPES) { + if(filter.respondsTo(scope)) return scope; + } + + throw new IllegalStateException("Filter type " + filter.getDefinitionType().getSimpleName() + + " does not have a filterable scope"); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/ItemMatcher.java b/PGM/src/main/java/tc/oc/pgm/filters/ItemMatcher.java new file mode 100644 index 0000000..57d87ec --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/ItemMatcher.java @@ -0,0 +1,45 @@ +package tc.oc.pgm.filters; + +import java.util.function.Predicate; +import javax.annotation.Nullable; + +import org.bukkit.inventory.ImItemStack; +import org.bukkit.inventory.ItemStack; +import tc.oc.commons.core.inspect.Inspectable; +import tc.oc.pgm.kits.tag.ItemTags; + +/** + * Logic used by item filters + */ +public class ItemMatcher extends Inspectable.Impl implements Predicate { + + @Inspect private final ImItemStack item; + + public ItemMatcher(ItemStack item) { + this.item = normalize(item.clone()).immutableCopy(); + } + + @Override + public boolean test(@Nullable ItemStack query) { + if(query == null) return false; + if(query.getType() != item.getType()) return false; + + query = normalize(query.clone()); + + // Match if items stack, and query stack is at least big as the base stack + return item.isSimilar(query) && query.getAmount() >= item.getAmount(); + } + + private static ItemStack normalize(ItemStack item) { + // Ignore durability (if it's actually durability, and not data) + if(item.getType().getMaxDurability() != 0) { + item.setDurability((short) 0); + } + + // Ignore these options + ItemTags.PREVENT_SHARING.clear(item); + ItemTags.LOCKED.clear(item); + + return item; + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/matcher/CauseFilter.java b/PGM/src/main/java/tc/oc/pgm/filters/matcher/CauseFilter.java new file mode 100644 index 0000000..cabd933 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/matcher/CauseFilter.java @@ -0,0 +1,171 @@ +package tc.oc.pgm.filters.matcher; + +import javax.inject.Inject; +import javax.inject.Provider; + +import com.google.inject.assistedinject.Assisted; +import org.bukkit.Material; +import org.bukkit.entity.Entity; +import org.bukkit.entity.LivingEntity; +import org.bukkit.entity.Player; +import org.bukkit.event.EntityAction; +import org.bukkit.event.Event; +import org.bukkit.event.block.BlockBreakEvent; +import org.bukkit.event.block.BlockBurnEvent; +import org.bukkit.event.block.BlockDamageEvent; +import org.bukkit.event.entity.EntityDamageEvent; +import org.bukkit.event.entity.EntityExplodeEvent; +import org.bukkit.event.player.PlayerBucketFillEvent; +import tc.oc.commons.bukkit.event.BlockPunchEvent; +import tc.oc.commons.bukkit.event.BlockTrampleEvent; +import tc.oc.commons.bukkit.event.GeneralizingEvent; +import tc.oc.pgm.filters.query.IEventQuery; +import tc.oc.pgm.tracker.EventResolver; +import tc.oc.pgm.tracker.damage.DamageInfo; +import tc.oc.pgm.tracker.damage.ItemInfo; +import tc.oc.pgm.tracker.damage.MeleeInfo; +import tc.oc.pgm.tracker.damage.PhysicalInfo; +import tc.oc.pgm.tracker.damage.PotionInfo; +import tc.oc.pgm.tracker.damage.ProjectileInfo; + +public class CauseFilter extends TypedFilter.Impl { + + // TODO: add other causes like growth, flow, mob, machine, etc. + public enum Cause { + // Actor types + WORLD, LIVING, MOB, PLAYER, + + // Block actions + PUNCH, TRAMPLE, MINE, + + // Damage types + MELEE, PROJECTILE, POTION, EXPLOSION, COMBUSTION, FALL, GRAVITY, VOID, + SQUASH, SUFFOCATION, DROWNING, STARVATION, LIGHTNING, CACTUS, THORNS; + } + + public interface Factory { + CauseFilter create(Cause cause); + } + + private final @Inspect Cause cause; + private final Provider eventResolverProvider; + + @Inject CauseFilter(@Assisted Cause cause, Provider eventResolverProvider) { + this.cause = cause; + this.eventResolverProvider = eventResolverProvider; + } + + public boolean matches(IEventQuery query) { + Event event = query.getEvent(); + if(event instanceof GeneralizingEvent) { + event = ((GeneralizingEvent) event).getCause(); + } + + EntityDamageEvent.DamageCause damageCause = null; + DamageInfo damageInfo = null; + boolean punchDamage = false; + if(event instanceof EntityDamageEvent) { + EntityDamageEvent damageEvent = (EntityDamageEvent) event; + damageCause = damageEvent.getCause(); + damageInfo = eventResolverProvider.get().resolveDamage(damageEvent); + if(damageInfo instanceof MeleeInfo) { + PhysicalInfo weapon = ((MeleeInfo) damageInfo).getWeapon(); + if(weapon instanceof ItemInfo && ((ItemInfo) weapon).getItem().getType() == Material.AIR) { + punchDamage = true; + } + } + } + + Entity actor = null; + if(event instanceof EntityAction) { + actor = ((EntityAction) event).getActor(); + } + + switch(this.cause) { + // Actor types + case WORLD: + return !(actor instanceof LivingEntity); + + case LIVING: + return actor instanceof LivingEntity; + + case MOB: + return actor instanceof LivingEntity && !(actor instanceof Player); + + case PLAYER: + return actor instanceof Player; + + // Block actions + case PUNCH: + return event instanceof BlockPunchEvent || punchDamage; + + case TRAMPLE: + return event instanceof BlockTrampleEvent; + + case MINE: + return event instanceof BlockDamageEvent || + event instanceof BlockBreakEvent || + event instanceof PlayerBucketFillEvent; + + // Damage types + case MELEE: + return damageCause == EntityDamageEvent.DamageCause.ENTITY_ATTACK || + damageInfo instanceof MeleeInfo; + + case PROJECTILE: + return damageCause == EntityDamageEvent.DamageCause.PROJECTILE || + damageInfo instanceof ProjectileInfo; + + case POTION: + return damageCause == EntityDamageEvent.DamageCause.MAGIC || + damageCause == EntityDamageEvent.DamageCause.POISON || + damageCause == EntityDamageEvent.DamageCause.WITHER || + damageInfo instanceof PotionInfo; + + case EXPLOSION: + return event instanceof EntityExplodeEvent || + damageCause == EntityDamageEvent.DamageCause.BLOCK_EXPLOSION || + damageCause == EntityDamageEvent.DamageCause.ENTITY_EXPLOSION; + + case COMBUSTION: + return event instanceof BlockBurnEvent || + damageCause == EntityDamageEvent.DamageCause.FIRE || + damageCause == EntityDamageEvent.DamageCause.FIRE_TICK || + damageCause == EntityDamageEvent.DamageCause.LAVA; + + case FALL: // Strictly damage from hitting the ground + return damageCause == EntityDamageEvent.DamageCause.FALL; + + case GRAVITY: // Any damage caused by a fall + return damageCause == EntityDamageEvent.DamageCause.FALL || + damageCause == EntityDamageEvent.DamageCause.VOID; + + case VOID: + return damageCause == EntityDamageEvent.DamageCause.VOID; + + case SQUASH: + return damageCause == EntityDamageEvent.DamageCause.FALLING_BLOCK; + + case SUFFOCATION: + return damageCause == EntityDamageEvent.DamageCause.SUFFOCATION; + + case DROWNING: + return damageCause == EntityDamageEvent.DamageCause.DROWNING; + + case STARVATION: + return damageCause == EntityDamageEvent.DamageCause.STARVATION; + + case LIGHTNING: + return damageCause == EntityDamageEvent.DamageCause.LIGHTNING; + + case CACTUS: + return damageCause == EntityDamageEvent.DamageCause.CONTACT; + + case THORNS: + return damageCause == EntityDamageEvent.DamageCause.THORNS; + + default: + return false; + } + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/matcher/QueryTypeFilter.java b/PGM/src/main/java/tc/oc/pgm/filters/matcher/QueryTypeFilter.java new file mode 100644 index 0000000..8b58fbe --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/matcher/QueryTypeFilter.java @@ -0,0 +1,16 @@ +package tc.oc.pgm.filters.matcher; + +import tc.oc.pgm.filters.query.IQuery; + +public class QueryTypeFilter extends TypedFilter.Impl { + protected final @Inspect Class type; + + public QueryTypeFilter(Class type) { + this.type = type; + } + + @Override + public boolean matches(IQuery query) { + return type.isInstance(query); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/matcher/StaticFilter.java b/PGM/src/main/java/tc/oc/pgm/filters/matcher/StaticFilter.java new file mode 100644 index 0000000..d8659f4 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/matcher/StaticFilter.java @@ -0,0 +1,50 @@ +package tc.oc.pgm.filters.matcher; + +import java.util.Optional; + +import org.bukkit.block.Block; +import org.bukkit.block.BlockState; +import tc.oc.pgm.filters.Filter; +import tc.oc.pgm.filters.query.IQuery; + +public class StaticFilter extends Filter.Impl { + protected final QueryResponse response; + + public StaticFilter(QueryResponse response) { + this.response = response; + } + + @Override + public Optional inspectIdentity() { + return Optional.of(response.name()); + } + + @Override + public boolean isDynamic() { + return response.isPresent(); + } + + @Override + public boolean respondsTo(Class queryType) { + return response.isPresent(); + } + + @Override + public QueryResponse query(IQuery query) { + return response; + } + + @Override + public QueryResponse query(Block block) { + return response; + } + + @Override + public QueryResponse query(BlockState block) { + return response; + } + + public static final StaticFilter ALLOW = new StaticFilter(QueryResponse.ALLOW); + public static final StaticFilter DENY = new StaticFilter(QueryResponse.DENY); + public static final StaticFilter ABSTAIN = new StaticFilter(QueryResponse.ABSTAIN); +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/matcher/TypedFilter.java b/PGM/src/main/java/tc/oc/pgm/filters/matcher/TypedFilter.java new file mode 100644 index 0000000..e84fe4d --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/matcher/TypedFilter.java @@ -0,0 +1,26 @@ +package tc.oc.pgm.filters.matcher; + +import tc.oc.pgm.filters.Filter; +import tc.oc.pgm.filters.query.IQuery; + +/** + * A filter that NEVER responds to queries outside of {@link #queryType()}, + * and ALWAYS responds to queries extending {@link #queryType()}. + * + * Queries of the latter type are passed to {@link #matches(IQuery)}. + */ +public interface TypedFilter extends WeakTypedFilter { + + @Override + default boolean respondsTo(Class queryType) { + return queryType().isAssignableFrom(queryType); + } + + default QueryResponse queryTyped(Q query) { + return QueryResponse.fromBoolean(matches(query)); + } + + boolean matches(Q query); + + abstract class Impl extends Filter.Impl implements TypedFilter {} +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/matcher/WeakTypedFilter.java b/PGM/src/main/java/tc/oc/pgm/filters/matcher/WeakTypedFilter.java new file mode 100644 index 0000000..15c88e0 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/matcher/WeakTypedFilter.java @@ -0,0 +1,30 @@ +package tc.oc.pgm.filters.matcher; + +import tc.oc.commons.core.reflect.TypeParameterCache; +import tc.oc.pgm.filters.Filter; +import tc.oc.pgm.filters.query.IQuery; + +/** + * A filter that NEVER responds to queries outside of {@link #queryType()}, + * and SOMETIMES responds to queries extending {@link #queryType()}. + * + * Queries of the latter type are passed to {@link #queryTyped(IQuery)}. + * + * The runtime type of {@link Q} is detected automatically if it + * is specified by a subclass. + */ +public interface WeakTypedFilter extends Filter { + + TypeParameterCache Q_CACHE = new TypeParameterCache<>(WeakTypedFilter.class, "Q"); + + default Class queryType() { + return (Class) Q_CACHE.resolveRaw(getClass()); + } + + default QueryResponse query(IQuery query) { + return queryType().isInstance(query) ? queryTyped((Q) query) + : QueryResponse.ABSTAIN; + } + + QueryResponse queryTyped(Q query); +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/matcher/block/MaterialFilter.java b/PGM/src/main/java/tc/oc/pgm/filters/matcher/block/MaterialFilter.java new file mode 100644 index 0000000..758932d --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/matcher/block/MaterialFilter.java @@ -0,0 +1,33 @@ +package tc.oc.pgm.filters.matcher.block; + +import org.bukkit.Material; +import org.bukkit.material.MaterialData; +import tc.oc.pgm.filters.Filter; +import tc.oc.pgm.filters.matcher.TypedFilter; +import tc.oc.pgm.filters.query.IMaterialQuery; +import tc.oc.pgm.utils.MaterialPattern; + +public class MaterialFilter extends TypedFilter.Impl { + private final @Inspect(inline=true) MaterialPattern pattern; + + public MaterialFilter(MaterialData materialData) { + this(new MaterialPattern(materialData)); + } + + public MaterialFilter(Material material) { + this(new MaterialPattern(material)); + } + + public MaterialFilter(MaterialPattern pattern) { + this.pattern = pattern; + } + + public static Filter of(MaterialPattern pattern) { + return new MaterialFilter(pattern); + } + + @Override + public boolean matches(IMaterialQuery query) { + return pattern.matches(query.getMaterial()); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/matcher/block/StructuralLoadFilter.java b/PGM/src/main/java/tc/oc/pgm/filters/matcher/block/StructuralLoadFilter.java new file mode 100644 index 0000000..9a7e1a0 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/matcher/block/StructuralLoadFilter.java @@ -0,0 +1,29 @@ +package tc.oc.pgm.filters.matcher.block; + +import tc.oc.pgm.fallingblocks.FallingBlocksMatchModule; +import tc.oc.pgm.filters.matcher.TypedFilter; +import tc.oc.pgm.filters.query.IBlockQuery; + +/** + * NOTE: this is potentially a very EXPENSIVE filter to apply, so XML authors should take + * care to avoid evaluating it whenever possible, by placing other filters above it. They + * should be particularly careful not to apply it to any events that modify large amounts + * of blocks all at once, such as explosions. + * + * The XML documentation should note all of this. + */ +public class StructuralLoadFilter extends TypedFilter.Impl { + + private final @Inspect int threshold; + + public StructuralLoadFilter(int threshold) { + this.threshold = threshold; + } + + @Override + public boolean matches(IBlockQuery query) { + return query.module(FallingBlocksMatchModule.class) + .map(fbmm -> fbmm.countUnsupportedNeighbors(query.getBlock().getBlock(), threshold) >= threshold) + .orElseGet(() -> 0 >= threshold); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/matcher/block/VoidFilter.java b/PGM/src/main/java/tc/oc/pgm/filters/matcher/block/VoidFilter.java new file mode 100644 index 0000000..0108fde --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/matcher/block/VoidFilter.java @@ -0,0 +1,26 @@ +package tc.oc.pgm.filters.matcher.block; + +import org.bukkit.Material; +import org.bukkit.block.BlockState; +import tc.oc.pgm.filters.matcher.TypedFilter; +import tc.oc.pgm.filters.query.IBlockQuery; +import tc.oc.pgm.listeners.WorldProblemMatchModule; + +/** + * Matches blocks that have only air/void below them + */ +public class VoidFilter extends TypedFilter.Impl { + + @Override + public boolean matches(IBlockQuery query) { + final BlockState block = query.getBlock(); + return block.getY() == 0 || + (!query.getMatch().needMatchModule(WorldProblemMatchModule.class).wasBlock36(block.getX(), 0, block.getZ()) && + block.getWorld().getBlockAt(block.getX(), 0, block.getZ()).getType() == Material.AIR); + } + + @Override + public String toString() { + return "VoidFilter{}"; + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/matcher/damage/AttackerFilter.java b/PGM/src/main/java/tc/oc/pgm/filters/matcher/damage/AttackerFilter.java new file mode 100644 index 0000000..7c1dd82 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/matcher/damage/AttackerFilter.java @@ -0,0 +1,22 @@ +package tc.oc.pgm.filters.matcher.damage; + +import java.util.Optional; + +import tc.oc.pgm.filters.Filter; +import tc.oc.pgm.filters.operator.TransformedFilter; +import tc.oc.pgm.filters.query.IDamageQuery; +import tc.oc.pgm.filters.query.PlayerEventQuery; + +public class AttackerFilter extends TransformedFilter { + + public AttackerFilter(Filter child) { + super(child); + } + + @Override + protected Optional transformQuery(IDamageQuery query) { + return query.getDamageInfo() + .attacker() + .map(attacker -> new PlayerEventQuery(attacker, query.getEvent())); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/matcher/damage/DamagerFilter.java b/PGM/src/main/java/tc/oc/pgm/filters/matcher/damage/DamagerFilter.java new file mode 100644 index 0000000..780aca9 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/matcher/damage/DamagerFilter.java @@ -0,0 +1,42 @@ +package tc.oc.pgm.filters.matcher.damage; + +import java.util.Optional; + +import org.bukkit.entity.Entity; +import org.bukkit.event.Event; +import tc.oc.pgm.filters.Filter; +import tc.oc.pgm.filters.operator.TransformedFilter; +import tc.oc.pgm.filters.query.IDamageQuery; +import tc.oc.pgm.filters.query.IEntityEventQuery; +import tc.oc.pgm.match.Match; +import tc.oc.pgm.tracker.damage.EntityInfo; + +public class DamagerFilter extends TransformedFilter { + + public DamagerFilter(Filter filter) { + super(filter); + } + + @Override + protected Optional transformQuery(IDamageQuery query) { + return query.getDamageInfo() + .damager() + .filter(damager -> damager instanceof EntityInfo) + .map(damager -> new IEntityEventQuery() { + @Override + public Class getEntityType() { + return ((EntityInfo) damager).getEntityClass(); + } + + @Override + public Event getEvent() { + return query.getEvent(); + } + + @Override + public Match getMatch() { + return query.getMatch(); + } + }); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/matcher/damage/RelationFilter.java b/PGM/src/main/java/tc/oc/pgm/filters/matcher/damage/RelationFilter.java new file mode 100644 index 0000000..cc9bb3c --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/matcher/damage/RelationFilter.java @@ -0,0 +1,19 @@ +package tc.oc.pgm.filters.matcher.damage; + +import tc.oc.pgm.filters.matcher.TypedFilter; +import tc.oc.pgm.filters.query.IDamageQuery; +import tc.oc.pgm.match.PlayerRelation; + +public class RelationFilter extends TypedFilter.Impl { + + private final @Inspect PlayerRelation relation; + + public RelationFilter(PlayerRelation relation) { + this.relation = relation; + } + + @Override + public boolean matches(IDamageQuery query) { + return relation.are(query.getVictim(), query.getDamageInfo().getAttacker()); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/matcher/damage/VictimFilter.java b/PGM/src/main/java/tc/oc/pgm/filters/matcher/damage/VictimFilter.java new file mode 100644 index 0000000..9668f7d --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/matcher/damage/VictimFilter.java @@ -0,0 +1,20 @@ +package tc.oc.pgm.filters.matcher.damage; + +import java.util.Optional; + +import tc.oc.pgm.filters.Filter; +import tc.oc.pgm.filters.operator.TransformedFilter; +import tc.oc.pgm.filters.query.IDamageQuery; +import tc.oc.pgm.filters.query.PlayerEventQuery; + +public class VictimFilter extends TransformedFilter { + + public VictimFilter(Filter child) { + super(child); + } + + @Override + protected Optional transformQuery(IDamageQuery query) { + return Optional.of(new PlayerEventQuery(query.getVictim(), query.getEvent())); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/matcher/entity/EntityTypeFilter.java b/PGM/src/main/java/tc/oc/pgm/filters/matcher/entity/EntityTypeFilter.java new file mode 100644 index 0000000..a49983b --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/matcher/entity/EntityTypeFilter.java @@ -0,0 +1,32 @@ +package tc.oc.pgm.filters.matcher.entity; + +import org.bukkit.entity.Entity; +import org.bukkit.entity.EntityType; +import tc.oc.pgm.filters.matcher.TypedFilter; +import tc.oc.pgm.filters.query.IEntityTypeQuery; + +public class EntityTypeFilter extends TypedFilter.Impl { + private final @Inspect Class type; + + public EntityTypeFilter(Class type) { + this.type = type; + } + + public EntityTypeFilter(EntityType type) { + this(type.getEntityClass()); + } + + public Class getEntityType() { + return type; + } + + @Override + public boolean matches(IEntityTypeQuery query) { + return type.isAssignableFrom(query.getEntityType()); + } + + @Override + public String toString() { + return getClass().getSimpleName() + "{type=" + this.type.getSimpleName() + "}"; + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/matcher/entity/LegacyWorldFilter.java b/PGM/src/main/java/tc/oc/pgm/filters/matcher/entity/LegacyWorldFilter.java new file mode 100644 index 0000000..dfccb3b --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/matcher/entity/LegacyWorldFilter.java @@ -0,0 +1,15 @@ +package tc.oc.pgm.filters.matcher.entity; + +import tc.oc.pgm.filters.matcher.TypedFilter; +import tc.oc.pgm.filters.query.IEntityTypeQuery; +import tc.oc.pgm.filters.query.IQuery; + +/** + * Used to implement the legacy "allow-world" and "deny-world" filters + */ +public class LegacyWorldFilter extends TypedFilter.Impl { + @Override + public boolean matches(IQuery query) { + return !(query instanceof IEntityTypeQuery); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/matcher/entity/SpawnReasonFilter.java b/PGM/src/main/java/tc/oc/pgm/filters/matcher/entity/SpawnReasonFilter.java new file mode 100644 index 0000000..0323273 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/matcher/entity/SpawnReasonFilter.java @@ -0,0 +1,23 @@ +package tc.oc.pgm.filters.matcher.entity; + +import org.bukkit.event.entity.CreatureSpawnEvent.SpawnReason; +import tc.oc.pgm.filters.matcher.TypedFilter; +import tc.oc.pgm.filters.query.IEntitySpawnQuery; + +public class SpawnReasonFilter extends TypedFilter.Impl { + protected final @Inspect SpawnReason reason; + + public SpawnReasonFilter(SpawnReason reason) { + this.reason = reason; + } + + @Override + public boolean matches(IEntitySpawnQuery query) { + return reason == query.getSpawnReason(); + } + + @Override + public String toString() { + return getClass().getSimpleName() + "{reason=" + this.reason + "}"; + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/matcher/match/FlagStateFilter.java b/PGM/src/main/java/tc/oc/pgm/filters/matcher/match/FlagStateFilter.java new file mode 100644 index 0000000..05c0db9 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/matcher/match/FlagStateFilter.java @@ -0,0 +1,43 @@ +package tc.oc.pgm.filters.matcher.match; + +import java.util.Optional; + +import tc.oc.pgm.filters.matcher.TypedFilter; +import tc.oc.pgm.filters.query.IMatchQuery; +import tc.oc.pgm.flag.FlagDefinition; +import tc.oc.pgm.flag.Post; +import tc.oc.pgm.flag.state.State; + +public class FlagStateFilter extends TypedFilter.Impl { + + private final @Inspect(brief=true) FlagDefinition flag; + private final @Inspect(brief=true) Optional post; + private final @Inspect Class state; + + public FlagStateFilter(FlagDefinition flag, Optional post, Class state) { + this.flag = flag; + this.post = post; + this.state = state; + } + + @Override + public String inspectType() { + return "FlagState"; + } + + @Override + public String toString() { + return inspect(); + } + + @Override + public boolean isDynamic() { + return true; + } + + @Override + public boolean matches(IMatchQuery query) { + final State current = query.feature(flag).state(); + return state.isInstance(current) && post.map(current::isAtPost).orElse(true); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/matcher/match/LegacyRandomFilter.java b/PGM/src/main/java/tc/oc/pgm/filters/matcher/match/LegacyRandomFilter.java new file mode 100644 index 0000000..f75fe88 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/matcher/match/LegacyRandomFilter.java @@ -0,0 +1,27 @@ +package tc.oc.pgm.filters.matcher.match; + +import com.google.common.collect.Range; +import tc.oc.commons.core.random.SaltedEntropy; +import tc.oc.pgm.filters.matcher.TypedFilter; +import tc.oc.pgm.filters.query.IMatchQuery; + +/** + * Random filter that responds to non-event queries, which exposes a lot of undefined behavior. + * + * Removed in proto 1.4.1 + */ +public class LegacyRandomFilter extends TypedFilter.Impl { + + private final @Inspect Range chance; + + public LegacyRandomFilter(Range chance) { + this.chance = chance; + } + + @Override + public boolean matches(IMatchQuery query) { + return chance.contains(new SaltedEntropy(query.getMatch().entropyForTick(), + query.randomSeed()) + .randomDouble()); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/matcher/match/MatchMutationFilter.java b/PGM/src/main/java/tc/oc/pgm/filters/matcher/match/MatchMutationFilter.java new file mode 100644 index 0000000..80ce970 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/matcher/match/MatchMutationFilter.java @@ -0,0 +1,21 @@ +package tc.oc.pgm.filters.matcher.match; + +import tc.oc.pgm.filters.matcher.TypedFilter; +import tc.oc.pgm.filters.query.IMatchQuery; +import tc.oc.pgm.mutation.Mutation; +import tc.oc.pgm.mutation.MutationMatchModule; + +public class MatchMutationFilter extends TypedFilter.Impl { + protected final @Inspect Mutation mutation; + + public MatchMutationFilter(Mutation mutation) { + this.mutation = mutation; + } + + @Override + public boolean matches(IMatchQuery query) { + return query.module(MutationMatchModule.class) + .filter(mmm -> mmm.getActiveMutations().contains(mutation)) + .isPresent(); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/matcher/match/MatchStateFilter.java b/PGM/src/main/java/tc/oc/pgm/filters/matcher/match/MatchStateFilter.java new file mode 100644 index 0000000..729fcbc --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/matcher/match/MatchStateFilter.java @@ -0,0 +1,42 @@ +package tc.oc.pgm.filters.matcher.match; + +import java.util.Arrays; +import java.util.Collection; +import java.util.EnumSet; + +import tc.oc.commons.core.util.EnumSets; +import tc.oc.pgm.filters.Filter; +import tc.oc.pgm.filters.matcher.TypedFilter; +import tc.oc.pgm.filters.query.IMatchQuery; +import tc.oc.pgm.match.MatchState; + +public class MatchStateFilter extends TypedFilter.Impl { + + private final @Inspect EnumSet states; + + public MatchStateFilter(MatchState... states) { + this(Arrays.asList(states)); + } + + public MatchStateFilter(Collection states) { + this.states = EnumSets.copySet(MatchState.class, states); + } + + @Override + public boolean isDynamic() { + return true; + } + + @Override + public boolean matches(IMatchQuery query) { + return states.contains(query.matchState()); + } + + private static final MatchStateFilter STARTED = new MatchStateFilter(MatchState.Running, MatchState.Finished); + private static final MatchStateFilter RUNNING = new MatchStateFilter(MatchState.Running); + private static final MatchStateFilter FINISHED = new MatchStateFilter(MatchState.Finished); + + public static Filter started() { return STARTED; } + public static Filter running() { return RUNNING; } + public static Filter finished() { return FINISHED; } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/matcher/match/MonostableFilter.java b/PGM/src/main/java/tc/oc/pgm/filters/matcher/match/MonostableFilter.java new file mode 100644 index 0000000..f4582ca --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/matcher/match/MonostableFilter.java @@ -0,0 +1,215 @@ +package tc.oc.pgm.filters.matcher.match; + +import java.time.Duration; +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +import net.md_5.bungee.api.ChatColor; +import org.bukkit.boss.BarColor; +import org.bukkit.entity.Player; +import tc.oc.commons.bukkit.localization.MessageTemplate; +import tc.oc.time.PeriodConverters; +import tc.oc.time.PeriodRenderers; +import tc.oc.commons.core.util.Comparables; +import tc.oc.commons.core.util.MapUtils; +import tc.oc.commons.core.util.TimeUtils; +import tc.oc.pgm.bossbar.BossBarMatchModule; +import tc.oc.pgm.countdowns.CountdownBossBarSource; +import tc.oc.pgm.features.Feature; +import tc.oc.pgm.features.FeatureDefinitionContext; +import tc.oc.pgm.features.FeatureFactory; +import tc.oc.pgm.features.FeatureValidationContext; +import tc.oc.pgm.filters.Filter; +import tc.oc.pgm.filters.FilterMatchModule; +import tc.oc.pgm.filters.Filterable; +import tc.oc.pgm.filters.Filterables; +import tc.oc.pgm.filters.matcher.TypedFilter; +import tc.oc.pgm.filters.operator.SingleFilterFunction; +import tc.oc.pgm.filters.parser.DynamicFilterValidation; +import tc.oc.pgm.filters.parser.RespondsToQueryValidation; +import tc.oc.pgm.filters.query.IMatchQuery; +import tc.oc.pgm.match.Match; +import tc.oc.pgm.match.MatchPlayer; +import tc.oc.pgm.match.Repeatable; +import tc.oc.pgm.xml.InvalidXMLException; + +/** + * Filter function that is low when its operand is low, and high only for + * a specified time after its operand goes high. + */ +public class MonostableFilter extends SingleFilterFunction implements TypedFilter, + FeatureFactory> { + private final @Inspect Duration duration; + private final @Inspect Optional message; + private final boolean colons; + + public MonostableFilter(Duration duration, Filter trigger, Optional message) { + super(trigger); + this.duration = duration; + this.message = message; + this.colons = Comparables.greaterOrEqual(duration, Duration.ofSeconds(90)); + } + + public static Filter after(FeatureDefinitionContext context, Duration time) throws InvalidXMLException { + return MatchStateFilter.running().and( + // Must be in the FDC so load() is called + context.define(Filter.class, + new MonostableFilter( + time, + MatchStateFilter.running(), + Optional.empty() + ) + ).not() + ); + } + + @Override + public void validate(FeatureValidationContext context) throws InvalidXMLException { + context.validate(filter, DynamicFilterValidation.INSTANCE); + context.validate(filter, RespondsToQueryValidation.get(message.isPresent() ? MatchPlayer.class : Match.class)); + } + + @Override + public void load(Match match) { + match.feature(this); + } + + @Override + public Reactor createFeature(Match match) { + return new Reactor<>(match, Filterables.scope(filter)); + } + + @Override + public boolean matches(IMatchQuery query) { + return query.feature(this).matches(query); + } + + class Reactor> implements Feature { + + final Match match; + final FilterMatchModule fmm; + final BossBarMatchModule bbmm; + + final Class scope; + final Optional bar; + + // Filterables that currently pass the inner filter, mapped to the instants that they expire. + // They are not actually removed until the inner filter goes false. + final Map endTimes = new HashMap<>(); + + Instant lastTick = TimeUtils.INF_PAST; + + Reactor(Match match, Class scope) { + this.match = match; + this.scope = scope; + this.bbmm = match.needMatchModule(BossBarMatchModule.class); + this.fmm = match.needMatchModule(FilterMatchModule.class); + + fmm.onChange(scope, filter, this::matches); + + bar = message.map(Bar::new); + bar.ifPresent(bar -> { + // If a countdown message is specified, register a global BossBarSource. + // Every player will have a view of this source, but it will hide itself + // at rendering time for viewers that do not pass the filter. + bbmm.add(bar); + + // Invalidate the bar when this filter (not the inner filter) changes for any player. + // It's easier to do this with a seperate listener, rather than trying to do it in the + // trigger listener, because that listener may be at a more general scope and won't always + // be called when the filter changes for individual players, e.g. if it's party scoped and + // a player changes parties. + fmm.onChange(MatchPlayer.class, MonostableFilter.this, (player, response) -> { + bbmm.invalidate(bar, player.getBukkit()); + }); + }); + } + + void invalidate(F filterable) { + fmm.invalidate(getDefinition(), filterable); + } + + boolean matches(IMatchQuery query) { + return query.filterable(scope) + .filter(filterable -> matches(filterable, filter.response(query))) + .isPresent(); + } + + boolean matches(F filterable, boolean response) { + if(response) { + final Instant now = match.getInstantNow(); + final Instant end = endTimes.computeIfAbsent(filterable, f -> { + invalidate(filterable); + return now.plus(duration); + }); + return now.isBefore(end); + } else { + if(endTimes.remove(filterable) != null) { + invalidate(filterable); + } + return false; + } + } + + @Repeatable + void tick() { + final Instant now = match.getInstantNow(); + + endTimes.forEach((filterable, end) -> { + if(now.isBefore(end)) { + // If the entry is still valid, check if its elapsed time crossed a second + // boundary over the last tick, and invalidate the boss bar if it did. + bar.ifPresent(bar -> { + if(Duration.between(lastTick, end).getSeconds() != Duration.between(now, end).getSeconds()) { + filterable.filterableDescendants(MatchPlayer.class) + .forEach(player -> bbmm.invalidate(bar, player.getBukkit())); + } + }); + } else if(lastTick.isBefore(end)) { + // If entry is expired, but was not expired last tick, invalidate this filter + invalidate(filterable); + } + }); + + lastTick = now; + } + + @Override + public MonostableFilter getDefinition() { + return MonostableFilter.this; + } + + class Bar extends CountdownBossBarSource { + final MessageTemplate message; + + Bar(MessageTemplate message) { + super(duration, ChatColor.YELLOW, ChatColor.AQUA, + colons ? PeriodConverters.normalized() : PeriodConverters.seconds(), + colons ? PeriodRenderers.colons() : PeriodRenderers.natural()); + + this.message = message; + } + + @Override + protected MessageTemplate barMessage(Player viewer) { + return message; + } + + @Override + protected Optional barTime(Player viewer) { + return match.player(viewer) + .flatMap(player -> player.filterableAncestor(scope)) + .flatMap(filterable -> MapUtils.value(endTimes, filterable)) + .flatMap(end -> TimeUtils.positiveDuration(match.getInstantNow(), end)); + } + + @Override + public BarColor barColor(Player viewer) { + return BarColor.YELLOW; + } + } + } +} + diff --git a/PGM/src/main/java/tc/oc/pgm/filters/matcher/match/PlayerCountFilter.java b/PGM/src/main/java/tc/oc/pgm/filters/matcher/match/PlayerCountFilter.java new file mode 100644 index 0000000..8dff097 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/matcher/match/PlayerCountFilter.java @@ -0,0 +1,95 @@ +package tc.oc.pgm.filters.matcher.match; + +import java.util.HashSet; +import java.util.Set; +import java.util.stream.Stream; + +import com.google.common.collect.Range; +import tc.oc.pgm.features.Feature; +import tc.oc.pgm.features.FeatureDefinition; +import tc.oc.pgm.features.FeatureFactory; +import tc.oc.pgm.filters.Filter; +import tc.oc.pgm.filters.FilterListener; +import tc.oc.pgm.filters.FilterMatchModule; +import tc.oc.pgm.filters.matcher.TypedFilter; +import tc.oc.pgm.filters.matcher.player.ParticipatingFilter; +import tc.oc.pgm.filters.query.IMatchQuery; +import tc.oc.pgm.match.Match; +import tc.oc.pgm.match.MatchPlayer; + +public class PlayerCountFilter extends TypedFilter.Impl implements FeatureFactory { + + private final @Inspect Range range; + private final @Inspect Filter filter; + + public PlayerCountFilter(Filter filter, Range range, boolean participants, boolean observers) { + this.range = range; + if(!observers) { + filter = ParticipatingFilter.PARTICIPATING.and(filter); + } + if(!participants) { + filter = ParticipatingFilter.OBSERVING.and(filter); + } + this.filter = filter; + } + + @Override + public Stream dependencies() { + return Stream.of(filter); + } + + @Override + public boolean isDynamic() { + return true; + } + + @Override + public boolean matches(IMatchQuery query) { + return query.feature(this).response(); + } + + @Override + public Reactor createFeature(Match match) { + return new Reactor(match); + } + + @Override + public void load(Match match) { + match.features().get(this); + } + + class Reactor implements Feature, FilterListener { + + private final FilterMatchModule fmm; + private final Set players = new HashSet<>(); + + Reactor(Match match) { + this.fmm = match.needMatchModule(FilterMatchModule.class); + fmm.onChange(MatchPlayer.class, filter, this); + } + + @Override + public PlayerCountFilter getDefinition() { + return PlayerCountFilter.this; + } + + boolean response() { + return range.contains(players.size()); + } + + @Override + public void filterQueryChanged(MatchPlayer filterable, boolean response) { + final boolean before = response(); + + if(response) { + players.add(filterable); + } else { + players.remove(filterable); + } + + if(before != response()) { + fmm.invalidate(filterable.getMatch()); + } + } + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/matcher/match/RandomFilter.java b/PGM/src/main/java/tc/oc/pgm/filters/matcher/match/RandomFilter.java new file mode 100644 index 0000000..e3fd6b0 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/matcher/match/RandomFilter.java @@ -0,0 +1,22 @@ +package tc.oc.pgm.filters.matcher.match; + +import com.google.common.collect.Range; +import tc.oc.pgm.filters.matcher.TypedFilter; +import tc.oc.pgm.filters.query.ITransientQuery; + +/** + * Return a pseudo-random result derived from the query and current tick. + */ +public class RandomFilter extends TypedFilter.Impl { + + private final @Inspect Range chance; + + public RandomFilter(Range chance) { + this.chance = chance; + } + + @Override + public boolean matches(ITransientQuery query) { + return chance.contains(query.entropy().randomDouble()); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/matcher/party/CompetitorFilter.java b/PGM/src/main/java/tc/oc/pgm/filters/matcher/party/CompetitorFilter.java new file mode 100644 index 0000000..6682052 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/matcher/party/CompetitorFilter.java @@ -0,0 +1,37 @@ +package tc.oc.pgm.filters.matcher.party; + +import tc.oc.pgm.filters.matcher.TypedFilter; +import tc.oc.pgm.filters.query.IMatchQuery; +import tc.oc.pgm.filters.query.IPartyQuery; +import tc.oc.pgm.match.Competitor; +import tc.oc.pgm.match.Party; + +/** + * A filter that can be applied to single {@link Competitor}s, or all + * {@link Competitor}s in the match (in which case it should effectively + * OR all of the responses). + * + * Any other type of {@link Party} is denied. + */ +public abstract class CompetitorFilter extends TypedFilter.Impl { + + /** + * Does ANY {@link Competitor} match the filter? + * + * The base method queries each competitor one by one. + */ + public boolean matchesAny(IMatchQuery query) { + return query.competitors() + .anyMatch(competitor -> matches(query, competitor)); + } + + /** + * Respond to the given {@link Competitor} + */ + public abstract boolean matches(IMatchQuery query, Competitor competitor); + + @Override + public final boolean matches(IPartyQuery query) { + return query.getParty() instanceof Competitor && matches(query, (Competitor) query.getParty()); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/matcher/party/GoalFilter.java b/PGM/src/main/java/tc/oc/pgm/filters/matcher/party/GoalFilter.java new file mode 100644 index 0000000..8090f41 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/matcher/party/GoalFilter.java @@ -0,0 +1,44 @@ +package tc.oc.pgm.filters.matcher.party; + +import java.util.Optional; + +import tc.oc.pgm.filters.query.IMatchQuery; +import tc.oc.pgm.goals.GoalDefinition; +import tc.oc.pgm.match.Competitor; + +public class GoalFilter extends CompetitorFilter { + private final @Inspect(brief = true) GoalDefinition goal; + + public GoalFilter(GoalDefinition goal) { + this.goal = goal; + } + + @Override + public String inspectType() { + return "Goal"; + } + + @Override + public String toString() { + return inspect(); + } + + @Override + public boolean isDynamic() { + return true; + } + + public boolean matches(IMatchQuery query, Optional competitor) { + return goal.getGoal(query.getMatch()).isCompleted(competitor); + } + + @Override + public boolean matchesAny(IMatchQuery query) { + return matches(query, Optional.empty()); + } + + @Override + public boolean matches(IMatchQuery query, Competitor competitor) { + return matches(query, Optional.of(competitor)); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/matcher/party/RankFilter.java b/PGM/src/main/java/tc/oc/pgm/filters/matcher/party/RankFilter.java new file mode 100644 index 0000000..5b56e7a --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/matcher/party/RankFilter.java @@ -0,0 +1,28 @@ +package tc.oc.pgm.filters.matcher.party; + +import com.google.common.collect.Range; +import tc.oc.pgm.filters.query.IMatchQuery; +import tc.oc.pgm.match.Competitor; +import tc.oc.pgm.victory.VictoryMatchModule; + +/** + * Match whether a {@link Competitor}'s rank is within a range. + */ +public class RankFilter extends CompetitorFilter { + + private final @Inspect Range positions; + + public RankFilter(Range positions) { + this.positions = positions; + } + + @Override + public boolean isDynamic() { + return true; + } + + @Override + public boolean matches(IMatchQuery query, Competitor competitor) { + return positions.contains(competitor.getMatch().needMatchModule(VictoryMatchModule.class).rankedCompetitors().getPosition(competitor)); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/matcher/party/ScoreFilter.java b/PGM/src/main/java/tc/oc/pgm/filters/matcher/party/ScoreFilter.java new file mode 100644 index 0000000..5971cce --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/matcher/party/ScoreFilter.java @@ -0,0 +1,31 @@ +package tc.oc.pgm.filters.matcher.party; + +import com.google.common.collect.Range; +import tc.oc.pgm.filters.query.IMatchQuery; +import tc.oc.pgm.match.Competitor; +import tc.oc.pgm.score.ScoreMatchModule; + +/** + * Match whether a {@link Competitor}'s score is within a range. + */ +public class ScoreFilter extends CompetitorFilter { + + private final @Inspect Range scores; + + public ScoreFilter(Range scores) { + this.scores = scores; + } + + @Override + public boolean isDynamic() { + return true; + } + + @Override + public boolean matches(IMatchQuery query, Competitor competitor) { + return competitor.getMatch() + .module(ScoreMatchModule.class) + .filter(smm -> scores.contains((int) smm.getScore(competitor))) + .isPresent(); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/matcher/party/TeamFilter.java b/PGM/src/main/java/tc/oc/pgm/filters/matcher/party/TeamFilter.java new file mode 100644 index 0000000..52625af --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/matcher/party/TeamFilter.java @@ -0,0 +1,34 @@ +package tc.oc.pgm.filters.matcher.party; + +import tc.oc.pgm.filters.matcher.TypedFilter; +import tc.oc.pgm.filters.query.IPartyQuery; +import tc.oc.pgm.match.Party; +import tc.oc.pgm.teams.Team; +import tc.oc.pgm.teams.TeamFactory; + +/** + * Match the given team, or a player on that team + */ +public class TeamFilter extends TypedFilter.Impl { + protected final @Inspect(brief=true) TeamFactory team; + + public TeamFilter(TeamFactory team) { + this.team = team; + } + + @Override + public boolean isDynamic() { + return true; + } + + @Override + public boolean matches(IPartyQuery query) { + final Party party = query.getParty(); + return party instanceof Team && ((Team) party).isDefinedBy(team); + } + + @Override + public String toString() { + return getClass().getSimpleName() + "{team=" + this.team + "}"; + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/matcher/player/AttributeFilter.java b/PGM/src/main/java/tc/oc/pgm/filters/matcher/player/AttributeFilter.java new file mode 100644 index 0000000..2fb5d54 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/matcher/player/AttributeFilter.java @@ -0,0 +1,26 @@ +package tc.oc.pgm.filters.matcher.player; + +import com.google.common.collect.Range; +import org.bukkit.attribute.Attribute; +import tc.oc.pgm.filters.matcher.TypedFilter; +import tc.oc.pgm.filters.query.IPlayerQuery; + +public class AttributeFilter extends TypedFilter.Impl { + + private final Attribute attribute; + private final Range range; + + public AttributeFilter(Attribute attribute, Range range) { + this.attribute = attribute; + this.range = range; + } + + @Override + public boolean matches(IPlayerQuery query) { + return query.onlinePlayer() + .filter(player -> range.contains(player.getBukkit() + .getAttribute(Attribute.GENERIC_LUCK) + .getValue())) + .isPresent(); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/matcher/player/CanFlyFilter.java b/PGM/src/main/java/tc/oc/pgm/filters/matcher/player/CanFlyFilter.java new file mode 100644 index 0000000..80dbb88 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/matcher/player/CanFlyFilter.java @@ -0,0 +1,11 @@ +package tc.oc.pgm.filters.matcher.player; + +import tc.oc.pgm.match.MatchPlayer; +import tc.oc.pgm.filters.query.IPlayerQuery; + +public class CanFlyFilter extends SpawnedPlayerFilter { + @Override + protected boolean matches(IPlayerQuery query, MatchPlayer player) { + return player.getBukkit().getAllowFlight(); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/matcher/player/CarryingFlagFilter.java b/PGM/src/main/java/tc/oc/pgm/filters/matcher/player/CarryingFlagFilter.java new file mode 100644 index 0000000..118ca2c --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/matcher/player/CarryingFlagFilter.java @@ -0,0 +1,33 @@ +package tc.oc.pgm.filters.matcher.player; + +import tc.oc.pgm.filters.matcher.TypedFilter; +import tc.oc.pgm.filters.query.IPartyQuery; +import tc.oc.pgm.filters.query.IPlayerQuery; +import tc.oc.pgm.flag.FlagDefinition; +import tc.oc.pgm.flag.state.State; + +public class CarryingFlagFilter extends TypedFilter.Impl { + + private final @Inspect(brief=true) FlagDefinition flag; + + public CarryingFlagFilter(FlagDefinition flag) { + this.flag = flag; + } + + @Override + public boolean isDynamic() { + return true; + } + + @Override + public boolean matches(IPartyQuery query) { + final State state = query.feature(flag).state(); + if(query instanceof IPlayerQuery) { + return ((IPlayerQuery) query).onlinePlayer() + .filter(state::isCarrying) + .isPresent(); + } else { + return state.isCarrying(query.getParty()); + } + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/matcher/player/CarryingItemFilter.java b/PGM/src/main/java/tc/oc/pgm/filters/matcher/player/CarryingItemFilter.java new file mode 100644 index 0000000..11bdc5c --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/matcher/player/CarryingItemFilter.java @@ -0,0 +1,15 @@ +package tc.oc.pgm.filters.matcher.player; + +import org.bukkit.inventory.ItemStack; +import tc.oc.pgm.match.MatchPlayer; + +public class CarryingItemFilter extends SpawnedPlayerItemFilter { + public CarryingItemFilter(ItemStack base) { + super(base); + } + + @Override + protected Iterable getItems(MatchPlayer player) { + return player.getBukkit().getInventory().contents(); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/matcher/player/HoldingItemFilter.java b/PGM/src/main/java/tc/oc/pgm/filters/matcher/player/HoldingItemFilter.java new file mode 100644 index 0000000..5c0c799 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/matcher/player/HoldingItemFilter.java @@ -0,0 +1,19 @@ +package tc.oc.pgm.filters.matcher.player; + +import java.util.Arrays; + +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +import tc.oc.pgm.match.MatchPlayer; + +public class HoldingItemFilter extends SpawnedPlayerItemFilter { + public HoldingItemFilter(ItemStack base) { + super(base); + } + + @Override + protected Iterable getItems(MatchPlayer player) { + final PlayerInventory inv = player.getBukkit().getInventory(); + return Arrays.asList(inv.getItemInMainHand(), inv.getItemInOffHand()); // List must allow nulls + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/matcher/player/KillStreakFilter.java b/PGM/src/main/java/tc/oc/pgm/filters/matcher/player/KillStreakFilter.java new file mode 100644 index 0000000..9d21a8c --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/matcher/player/KillStreakFilter.java @@ -0,0 +1,35 @@ +package tc.oc.pgm.filters.matcher.player; + +import com.google.common.collect.BoundType; +import com.google.common.collect.Range; +import tc.oc.pgm.playerstats.StatsUserFacet; +import tc.oc.pgm.match.MatchPlayer; +import tc.oc.pgm.filters.query.IPlayerQuery; + +public class KillStreakFilter extends SpawnedPlayerFilter { + private final @Inspect Range range; + private final @Inspect boolean repeat; + private final @Inspect boolean persistent; + + public KillStreakFilter(Range range, boolean repeat, boolean persistent) { + this.range = range; + this.repeat = repeat; + this.persistent = persistent; + } + + @Override + public boolean isDynamic() { + return true; + } + + @Override + protected boolean matches(IPlayerQuery query, MatchPlayer player) { + final StatsUserFacet facet = player.getUserContext().facet(StatsUserFacet.class); + int kills = persistent ? facet.teamKills() : facet.lifeKills(); + if(repeat && kills > 0) { + int modulo = this.range.upperEndpoint() - (this.range.upperBoundType() == BoundType.CLOSED ? 0 : 1); + kills = 1 + (kills - 1) % modulo; + } + return this.range.contains(kills); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/matcher/player/ParticipatingFilter.java b/PGM/src/main/java/tc/oc/pgm/filters/matcher/player/ParticipatingFilter.java new file mode 100644 index 0000000..c4062e3 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/matcher/player/ParticipatingFilter.java @@ -0,0 +1,55 @@ +package tc.oc.pgm.filters.matcher.player; + +import tc.oc.pgm.filters.Filter; +import tc.oc.pgm.filters.matcher.StaticFilter; +import tc.oc.pgm.filters.matcher.TypedFilter; +import tc.oc.pgm.filters.query.IPartyQuery; + +public class ParticipatingFilter extends TypedFilter.Impl { + + public static final Filter PARTICIPATING = new ParticipatingFilter(true); + public static final Filter OBSERVING = new ParticipatingFilter(false); + + public static Filter of(boolean response) { + return response ? PARTICIPATING : OBSERVING; + } + + public ParticipatingFilter(boolean participating) { + this.participating = participating; + } + + private final @Inspect boolean participating; + + @Override + public boolean isDynamic() { + return true; + } + + @Override + public boolean matches(IPartyQuery query) { + return query.isParticipating() == participating; + } + + @Override + public Filter not() { + return of(!participating); + } + + @Override + public Filter and(Filter that) { + if(that instanceof ParticipatingFilter) { + return this.participating == ((ParticipatingFilter) that).participating + ? this : StaticFilter.DENY; + } + return super.and(that); + } + + @Override + public Filter or(Filter that) { + if(that instanceof ParticipatingFilter) { + return this.participating == ((ParticipatingFilter) that).participating + ? this : StaticFilter.ALLOW; + } + return super.and(that); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/matcher/player/PlayerClassFilter.java b/PGM/src/main/java/tc/oc/pgm/filters/matcher/player/PlayerClassFilter.java new file mode 100644 index 0000000..7188655 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/matcher/player/PlayerClassFilter.java @@ -0,0 +1,25 @@ +package tc.oc.pgm.filters.matcher.player; + +import com.google.common.base.Preconditions; +import tc.oc.commons.core.util.Optionals; +import tc.oc.pgm.classes.ClassMatchModule; +import tc.oc.pgm.classes.PlayerClass; +import tc.oc.pgm.filters.matcher.TypedFilter; +import tc.oc.pgm.filters.query.IPlayerQuery; + +public class PlayerClassFilter extends TypedFilter.Impl { + private final @Inspect PlayerClass playerClass; + + public PlayerClassFilter(PlayerClass playerClass) { + this.playerClass = Preconditions.checkNotNull(playerClass, "player class"); + } + + @Override + public boolean matches(IPlayerQuery query) { + return Optionals.flatMapBoth(query.module(ClassMatchModule.class), + query.onlinePlayer(), + (cmm, player) -> cmm.lastPlayedClass(player.getPlayerId())) + .filter(playerClass::equals) + .isPresent(); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/matcher/player/PoseFilter.java b/PGM/src/main/java/tc/oc/pgm/filters/matcher/player/PoseFilter.java new file mode 100644 index 0000000..80c1771 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/matcher/player/PoseFilter.java @@ -0,0 +1,43 @@ +package tc.oc.pgm.filters.matcher.player; + +import com.google.common.cache.LoadingCache; +import org.bukkit.PoseFlag; +import tc.oc.commons.core.util.CacheUtils; +import tc.oc.pgm.filters.Filter; +import tc.oc.pgm.filters.matcher.TypedFilter; +import tc.oc.pgm.filters.operator.AnyFilter; +import tc.oc.pgm.filters.operator.InverseFilter; +import tc.oc.pgm.filters.query.IPoseQuery; + +public class PoseFilter extends TypedFilter.Impl { + + private static final LoadingCache CACHE = CacheUtils.newCache(PoseFilter::new); + + public static Filter of(PoseFlag pose) { + return CACHE.getUnchecked(pose); + } + + private static final Filter WALKING = new InverseFilter(AnyFilter.of(of(PoseFlag.SNEAKING), of(PoseFlag.SPRINTING))); + public static Filter walking() { return WALKING; } + + private final @Inspect PoseFlag pose; + + public PoseFilter(PoseFlag pose) { + this.pose = pose; + } + + @Override + public String toString() { + return "Pose{" + pose + "}"; + } + + @Override + public boolean isDynamic() { + return true; + } + + @Override + public boolean matches(IPoseQuery query) { + return query.getPose().contains(pose); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/matcher/player/SpawnedPlayerFilter.java b/PGM/src/main/java/tc/oc/pgm/filters/matcher/player/SpawnedPlayerFilter.java new file mode 100644 index 0000000..5ab30fb --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/matcher/player/SpawnedPlayerFilter.java @@ -0,0 +1,28 @@ +package tc.oc.pgm.filters.matcher.player; + +import tc.oc.pgm.filters.matcher.TypedFilter; +import tc.oc.pgm.filters.query.IPlayerQuery; +import tc.oc.pgm.match.MatchPlayer; + +/** + * Base for filters that apply to *online*, participating players. The base class + * returns DENY if the player is currently offline or observing, and abstains from + * non-player queries. + * + * This should only be inherited by filters that absolutely require an online + * {@link MatchPlayer} to match against. Generally, player filters should not rely + * on the player's current state, and instead use only the properties of the + * {@link IPlayerQuery} itself. + * + */ +public abstract class SpawnedPlayerFilter extends TypedFilter.Impl { + + protected abstract boolean matches(IPlayerQuery query, MatchPlayer player); + + @Override + public boolean matches(IPlayerQuery query) { + return query.participant(query.getPlayerId()) + .filter(player -> matches(query, player)) + .isPresent(); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/matcher/player/SpawnedPlayerItemFilter.java b/PGM/src/main/java/tc/oc/pgm/filters/matcher/player/SpawnedPlayerItemFilter.java new file mode 100644 index 0000000..b0e16ee --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/matcher/player/SpawnedPlayerItemFilter.java @@ -0,0 +1,32 @@ +package tc.oc.pgm.filters.matcher.player; + +import java.util.function.Predicate; + +import org.bukkit.inventory.ItemStack; +import tc.oc.pgm.filters.ItemMatcher; +import tc.oc.pgm.filters.query.IPlayerQuery; +import tc.oc.pgm.match.MatchPlayer; + +public abstract class SpawnedPlayerItemFilter extends SpawnedPlayerFilter { + + @Inspect(inline = true) + private final Predicate matcher; + + public SpawnedPlayerItemFilter(ItemStack base) { + this(new ItemMatcher(base)); + } + + public SpawnedPlayerItemFilter(Predicate matcher) { + this.matcher = matcher; + } + + protected abstract Iterable getItems(MatchPlayer player); + + @Override + protected boolean matches(IPlayerQuery query, MatchPlayer player) { + for(ItemStack item : getItems(player)) { + if(matcher.test(item)) return true; + } + return false; + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/matcher/player/WearingItemFilter.java b/PGM/src/main/java/tc/oc/pgm/filters/matcher/player/WearingItemFilter.java new file mode 100644 index 0000000..35d02db --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/matcher/player/WearingItemFilter.java @@ -0,0 +1,15 @@ +package tc.oc.pgm.filters.matcher.player; + +import org.bukkit.inventory.ItemStack; +import tc.oc.pgm.match.MatchPlayer; + +public class WearingItemFilter extends SpawnedPlayerItemFilter { + public WearingItemFilter(ItemStack base) { + super(base); + } + + @Override + protected Iterable getItems(MatchPlayer player) { + return player.getBukkit().getInventory().armor(); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/operator/AggregateFilter.java b/PGM/src/main/java/tc/oc/pgm/filters/operator/AggregateFilter.java new file mode 100644 index 0000000..2d0e6af --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/operator/AggregateFilter.java @@ -0,0 +1,46 @@ +package tc.oc.pgm.filters.operator; + +import tc.oc.pgm.filters.Filter; +import tc.oc.pgm.filters.FilterTypeException; +import tc.oc.pgm.filters.query.IQuery; + +import static tc.oc.commons.core.exception.LambdaExceptionUtils.rethrowConsumer; + +/** + * A {@link MultiFilterFunction} supporting dynamics. + * + * Filters extending this class have the extra constraint that + * they must respond to any query type that all of their child + * filters respond to, unless there are no child filters, in + * which case {@link #respondsTo(Class)} and {@link #isDynamic()} + * always return false. + * + * TODO: It would be better if the exception for empty children was + * not necessary, but for backwards compatibility, the logical + * filter operators abstain if they are empty. + */ +public abstract class AggregateFilter extends MultiFilterFunction { + + public AggregateFilter(Iterable filters) { + super(filters); + } + + @Override + public boolean respondsTo(Class queryType) { + return !filters.isEmpty() && + filters.stream().allMatch(f -> f.respondsTo(queryType)); + } + + @Override + public void assertRespondsTo(Class queryType) throws FilterTypeException { + if(filters.isEmpty()) { + throw new FilterTypeException(this, queryType); + } + filters.forEach(rethrowConsumer(f -> f.assertRespondsTo(queryType))); + } + + @Override + public boolean isDynamic() { + return !filters.isEmpty(); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/operator/AllFilter.java b/PGM/src/main/java/tc/oc/pgm/filters/operator/AllFilter.java new file mode 100644 index 0000000..40b2a11 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/operator/AllFilter.java @@ -0,0 +1,43 @@ +package tc.oc.pgm.filters.operator; + +import java.util.Arrays; + +import com.google.common.collect.Iterables; +import tc.oc.commons.core.IterableUtils; +import tc.oc.pgm.filters.Filter; +import tc.oc.pgm.filters.matcher.StaticFilter; +import tc.oc.pgm.filters.query.IQuery; + +public class AllFilter extends AggregateFilter { + + public AllFilter(Iterable filters) { + super(filters); + } + + public AllFilter(Filter... filters) { + this(Arrays.asList(filters)); + } + + @Override + public QueryResponse query(IQuery query) { + // returns true if all the filters match + QueryResponse response = QueryResponse.ABSTAIN; + for(Filter filter : this.filters) { + QueryResponse filterResponse = filter.query(query); + if(filterResponse == QueryResponse.DENY) { + return filterResponse; + } else if(filterResponse == QueryResponse.ALLOW) { + response = filterResponse; + } + } + return response; + } + + public static Filter of(Filter... filters) { + return of(Arrays.asList(filters)); + } + + public static Filter of(Iterable filters) { + return IterableUtils.unify(Iterables.filter(filters, filter -> !StaticFilter.ABSTAIN.equals(filter)), StaticFilter.ALLOW, AllFilter::new); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/operator/AnyFilter.java b/PGM/src/main/java/tc/oc/pgm/filters/operator/AnyFilter.java new file mode 100644 index 0000000..a3890a8 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/operator/AnyFilter.java @@ -0,0 +1,48 @@ +package tc.oc.pgm.filters.operator; + +import java.util.Arrays; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import tc.oc.commons.core.IterableUtils; +import tc.oc.pgm.filters.Filter; +import tc.oc.pgm.filters.matcher.StaticFilter; +import tc.oc.pgm.filters.query.IQuery; + +public class AnyFilter extends AggregateFilter { + + public AnyFilter(Iterable filters) { + super(filters); + } + + public AnyFilter(Filter... filters) { + this(Arrays.asList(filters)); + } + + @Override + public QueryResponse query(IQuery query) { + // returns true if any of the filters match + QueryResponse response = QueryResponse.ABSTAIN; + for(Filter filter : this.filters) { + QueryResponse filterResponse = filter.query(query); + if(filterResponse == QueryResponse.ALLOW) { + return filterResponse; + } else if (filterResponse == QueryResponse.DENY) { + response = filterResponse; + } + } + return response; + } + + public static Filter of(Filter... filters) { + return of(Arrays.asList(filters)); + } + + public static Filter of(Iterable filters) { + return IterableUtils.unify(filters, StaticFilter.DENY, AnyFilter::new); + } + + public static Filter of(Stream filters) { + return of(filters.collect(Collectors.toList())); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/operator/ChainFilter.java b/PGM/src/main/java/tc/oc/pgm/filters/operator/ChainFilter.java new file mode 100644 index 0000000..3ce71ef --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/operator/ChainFilter.java @@ -0,0 +1,45 @@ +package tc.oc.pgm.filters.operator; + +import java.util.List; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Lists; +import tc.oc.commons.core.IterableUtils; +import tc.oc.pgm.filters.Filter; +import tc.oc.pgm.filters.matcher.StaticFilter; +import tc.oc.pgm.filters.query.IQuery; + +/** + * Returns the result of the first child filter that does not abstain + */ +public class ChainFilter extends MultiFilterFunction { + + public ChainFilter(Iterable filters) { + super(filters); + } + + public static Filter forward(List filters) { + return IterableUtils.unify(filters, StaticFilter.ABSTAIN, ChainFilter::new); + } + + /** + * Return a reversed chain, so later filters have priority over earlier ones + */ + public static Filter reverse(List filters) { + return IterableUtils.unify(filters, StaticFilter.ABSTAIN, multi -> new ChainFilter(Lists.reverse(ImmutableList.copyOf(multi)))); + } + + @Override + public boolean respondsTo(Class queryType) { + return false; + } + + @Override + public QueryResponse query(IQuery query) { + for(Filter filter : filters) { + final QueryResponse response = filter.query(query); + if(response.isPresent()) return response; + } + return QueryResponse.ABSTAIN; + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/operator/FallthroughFilter.java b/PGM/src/main/java/tc/oc/pgm/filters/operator/FallthroughFilter.java new file mode 100644 index 0000000..357ec8f --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/operator/FallthroughFilter.java @@ -0,0 +1,61 @@ +package tc.oc.pgm.filters.operator; + +import java.util.Arrays; + +import tc.oc.pgm.filters.Filter; +import tc.oc.pgm.filters.matcher.StaticFilter; +import tc.oc.pgm.filters.query.IQuery; + +/** + * Return a fixed response if any child filter returns ALLOW, otherwise return ABSTAIN. + */ +public class FallthroughFilter extends MultiFilterFunction { + + private final QueryResponse response; + + public FallthroughFilter(QueryResponse response, Filter... filters) { + this(response, Arrays.asList(filters)); + } + + public FallthroughFilter(QueryResponse response, Iterable filters) { + super(filters); + this.response = response; + } + + public static Filter of(QueryResponse response, Filter... filters) { + return of(response, Arrays.asList(filters)); + } + + public static Filter of(QueryResponse response, Iterable filters) { + if(filters.iterator().hasNext()) { + return new FallthroughFilter(response, filters); + } else { + return StaticFilter.ABSTAIN; + } + } + + public static Filter allow(Iterable filters) { + return of(QueryResponse.ALLOW, filters); + } + + public static Filter deny(Iterable filters) { + return of(QueryResponse.DENY, filters); + } + + public static Filter deny(Filter... filters) { + return deny(Arrays.asList(filters)); + } + + @Override + public boolean respondsTo(Class queryType) { + return false; + } + + @Override + public QueryResponse query(IQuery query) { + for(Filter filter : filters) { + if(filter.query(query) == QueryResponse.ALLOW) return response; + } + return QueryResponse.ABSTAIN; + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/operator/FilterNode.java b/PGM/src/main/java/tc/oc/pgm/filters/operator/FilterNode.java new file mode 100644 index 0000000..55c853f --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/operator/FilterNode.java @@ -0,0 +1,15 @@ +package tc.oc.pgm.filters.operator; + +import java.util.List; + +import com.google.common.collect.ImmutableList; +import tc.oc.pgm.filters.Filter; + +@Deprecated +public class FilterNode extends ChainFilter { + public FilterNode(List parents, List allowedMatchers, List deniedMatchers) { + super(ImmutableList.of(FallthroughFilter.deny(deniedMatchers), + FallthroughFilter.allow(allowedMatchers), + ChainFilter.reverse(parents))); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/operator/InverseFilter.java b/PGM/src/main/java/tc/oc/pgm/filters/operator/InverseFilter.java new file mode 100644 index 0000000..1f39898 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/operator/InverseFilter.java @@ -0,0 +1,33 @@ +package tc.oc.pgm.filters.operator; + +import tc.oc.pgm.filters.Filter; +import tc.oc.pgm.filters.query.IQuery; + +/** + * Abstain if the child filter abstains, otherwise return the opposite of the child. + */ +public class InverseFilter extends SingleFilterFunction { + + public InverseFilter(Filter filter) { + super(filter); + } + + @Override + public String toString() { + return "Not{" + filter + "}"; + } + + @Override + public QueryResponse query(IQuery query) { + switch(this.filter.query(query)) { + case ALLOW: return QueryResponse.DENY; + case DENY: return QueryResponse.ALLOW; + default: return QueryResponse.ABSTAIN; + } + } + + @Override + public Filter not() { + return filter; + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/operator/MultiFilterFunction.java b/PGM/src/main/java/tc/oc/pgm/filters/operator/MultiFilterFunction.java new file mode 100644 index 0000000..c38ebf3 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/operator/MultiFilterFunction.java @@ -0,0 +1,36 @@ +package tc.oc.pgm.filters.operator; + +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import tc.oc.commons.core.util.Streams; +import tc.oc.pgm.filters.Filter; +import tc.oc.pgm.filters.matcher.StaticFilter; + +/** + * A filter that derives its response from the responses of multiple + * child filters to the same query. + * + * @see AggregateFilter which has stronger requirements + * @see SingleFilterFunction which operates on a single child filter + */ +public abstract class MultiFilterFunction extends Filter.Impl { + @Inspect protected final List filters; + + public MultiFilterFunction(Iterable filters) { + this.filters = Streams.of(filters) + .filter(f -> !f.equals(StaticFilter.ABSTAIN)) + .collect(Collectors.toList()); + } + + @Override + public Stream dependencies() { + return filters.stream(); + } + + @Override + public String toString() { + return inspect(); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/operator/OneFilter.java b/PGM/src/main/java/tc/oc/pgm/filters/operator/OneFilter.java new file mode 100644 index 0000000..5545fce --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/operator/OneFilter.java @@ -0,0 +1,46 @@ +package tc.oc.pgm.filters.operator; + +import tc.oc.commons.core.IterableUtils; +import tc.oc.pgm.filters.Filter; +import tc.oc.pgm.filters.matcher.StaticFilter; +import tc.oc.pgm.filters.query.IQuery; + +import java.util.Arrays; + +public class OneFilter extends AggregateFilter { + + public OneFilter(Iterable filters) { + super(filters); + } + + public OneFilter(Filter... filters) { + this(Arrays.asList(filters)); + } + + @Override + public QueryResponse query(IQuery query) { + // returns true if exactly one of the filters match + QueryResponse response = QueryResponse.ABSTAIN; + for(Filter filter : this.filters) { + QueryResponse filterResponse = filter.query(query); + if(filterResponse == QueryResponse.ALLOW) { + if(response == QueryResponse.ALLOW) { + return QueryResponse.DENY; + } else { + response = filterResponse; + } + } else if (filterResponse == QueryResponse.DENY) { + response = filterResponse; + } + } + return response; + } + + public static Filter of(Filter... filters) { + return of(Arrays.asList(filters)); + } + + public static Filter of(Iterable filters) { + return IterableUtils.unify(filters, StaticFilter.ABSTAIN, OneFilter::new); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/operator/SameTeamFilter.java b/PGM/src/main/java/tc/oc/pgm/filters/operator/SameTeamFilter.java new file mode 100644 index 0000000..15d859f --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/operator/SameTeamFilter.java @@ -0,0 +1,23 @@ +package tc.oc.pgm.filters.operator; + +import java.util.Optional; + +import tc.oc.pgm.filters.Filter; +import tc.oc.pgm.filters.query.IPartyQuery; +import tc.oc.pgm.filters.query.IPlayerQuery; + +/** + * Transforms a player query into a query on their team. + */ +public class SameTeamFilter extends TransformedFilter { + + public SameTeamFilter(Filter child) { + super(child); + } + + @Override + protected Optional transformQuery(IPartyQuery query) { + return Optional.of(query instanceof IPlayerQuery ? query.getParty() + : query); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/operator/SingleFilterFunction.java b/PGM/src/main/java/tc/oc/pgm/filters/operator/SingleFilterFunction.java new file mode 100644 index 0000000..64b6cca --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/operator/SingleFilterFunction.java @@ -0,0 +1,49 @@ +package tc.oc.pgm.filters.operator; + +import java.util.stream.Stream; + +import tc.oc.pgm.filters.Filter; +import tc.oc.pgm.filters.FilterTypeException; +import tc.oc.pgm.filters.query.IQuery; + +import static com.google.common.base.Preconditions.checkNotNull; + +/** + * A filter that forwards queries to a single child filter, and transforms the response in some way. + * + * @see MultiFilterFunction which operates on multiple child filters + * @see TransformedFilter which transforms the query, rather than the response + */ +public abstract class SingleFilterFunction extends Filter.Impl { + + protected final @Inspect Filter filter; + + public SingleFilterFunction(Filter filter) { + this.filter = checkNotNull(filter, "filter may not be null"); + } + + @Override + public Stream dependencies() { + return Stream.of(filter); + } + + @Override + public boolean respondsTo(Class queryType) { + return filter.respondsTo(queryType); + } + + @Override + public void assertRespondsTo(Class queryType) throws FilterTypeException { + filter.assertRespondsTo(queryType); + } + + @Override + public boolean isDynamic() { + return true; + } + + @Override + public String toString() { + return inspect(); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/operator/TeamFilterAdapter.java b/PGM/src/main/java/tc/oc/pgm/filters/operator/TeamFilterAdapter.java new file mode 100644 index 0000000..cd9829a --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/operator/TeamFilterAdapter.java @@ -0,0 +1,57 @@ +package tc.oc.pgm.filters.operator; + +import java.util.Optional; + +import tc.oc.pgm.filters.matcher.TypedFilter; +import tc.oc.pgm.filters.matcher.party.CompetitorFilter; +import tc.oc.pgm.filters.query.IMatchQuery; +import tc.oc.pgm.teams.TeamFactory; +import tc.oc.pgm.teams.TeamMatchModule; + +/** + * Adapts a {@link CompetitorFilter}, which depends on the party in the query, + * into a filter with an explicit {@link TeamFactory} that can respond to any + * {@link IMatchQuery}. + * + * The team can also be omitted, in which case this delegates to + * {@link CompetitorFilter#matchesAny(IMatchQuery)}. + * + * Note that this is not a {@link SingleFilterFunction}, because it is (currently) + * entirely transparent to the user. That is, it cannot be created directly + * through XML, it is only used to implement other filters. + * + * As a future enhancement, we could potentially allow it to be created directly, + * which might look something like this: + * + * + * 5 + * + */ +public class TeamFilterAdapter extends TypedFilter.Impl { + + private final @Inspect Optional team; + private final @Inspect CompetitorFilter filter; + + public TeamFilterAdapter(Optional team, CompetitorFilter filter) { + this.team = team; + this.filter = filter; + } + + @Override + public String toString() { + return inspect(); + } + + @Override + public boolean isDynamic() { + return filter.isDynamic(); + } + + @Override + public boolean matches(IMatchQuery query) { + return team.isPresent() ? query.module(TeamMatchModule.class) + .map(tmm -> filter.matches(query, tmm.team(team.get()))) + .orElse(false) + : filter.matchesAny(query); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/operator/TransformedFilter.java b/PGM/src/main/java/tc/oc/pgm/filters/operator/TransformedFilter.java new file mode 100644 index 0000000..bd8cbec --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/operator/TransformedFilter.java @@ -0,0 +1,52 @@ +package tc.oc.pgm.filters.operator; + +import java.util.Optional; + +import tc.oc.commons.core.reflect.TypeParameterCache; +import tc.oc.pgm.filters.Filter; +import tc.oc.pgm.filters.FilterTypeException; +import tc.oc.pgm.filters.matcher.WeakTypedFilter; +import tc.oc.pgm.filters.query.IQuery; + +/** + * A filter that transforms a query of type {@link Q} into some other query of type {@link R} + * and passes it to a child filter. + * + * @see SingleFilterFunction which transforms the response, rather than the query + */ +public abstract class TransformedFilter extends Filter.Impl implements WeakTypedFilter { + + private static final TypeParameterCache R_CACHE = new TypeParameterCache<>(TransformedFilter.class, "R"); + + private final Class innerQueryType = (Class) R_CACHE.resolveRaw(getClass()); + protected final @Inspect Filter filter; + + public TransformedFilter(Filter filter) { + this.filter = filter; + } + + @Override + public boolean respondsTo(Class queryType) { + return queryType().isAssignableFrom(queryType) && + filter.respondsTo(innerQueryType); + } + + @Override + public void assertRespondsTo(Class queryType) throws FilterTypeException { + if(!queryType().isAssignableFrom(queryType)) { + throw new FilterTypeException(this, queryType); + } + filter.assertRespondsTo(innerQueryType); + } + + @Override + public QueryResponse queryTyped(Q query) { + return transformQuery(query).map(filter::query) + .orElse(QueryResponse.DENY); + } + + /** + * Return a transformed query, or empty to DENY + */ + protected abstract Optional transformQuery(Q query); +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/parser/DynamicFilterValidation.java b/PGM/src/main/java/tc/oc/pgm/filters/parser/DynamicFilterValidation.java new file mode 100644 index 0000000..3cbffc3 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/parser/DynamicFilterValidation.java @@ -0,0 +1,25 @@ +package tc.oc.pgm.filters.parser; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import tc.oc.pgm.filters.Filter; +import tc.oc.pgm.xml.InvalidXMLException; +import tc.oc.pgm.xml.Node; +import tc.oc.pgm.xml.validate.Validation; + +@Singleton +public class DynamicFilterValidation implements Validation { + + public static final DynamicFilterValidation INSTANCE = new DynamicFilterValidation(); + + @Inject private DynamicFilterValidation() {} + + @Override + public void validate(Filter filter, Node node) throws InvalidXMLException { + if(!filter.isDynamic()) { + throw new InvalidXMLException("Filter type " + filter.getDefinitionType().getSimpleName() + + " cannot be used in a dynamic context", node); + } + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/parser/FilterDefinitionParser.java b/PGM/src/main/java/tc/oc/pgm/filters/parser/FilterDefinitionParser.java new file mode 100644 index 0000000..141bed3 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/parser/FilterDefinitionParser.java @@ -0,0 +1,492 @@ +package tc.oc.pgm.filters.parser; + +import java.time.Duration; +import java.util.Optional; +import javax.inject.Inject; +import javax.inject.Provider; + +import com.google.common.collect.Range; +import org.bukkit.PoseFlag; +import org.bukkit.attribute.Attribute; +import org.bukkit.entity.EntityType; +import org.bukkit.entity.LivingEntity; +import org.bukkit.event.entity.CreatureSpawnEvent; +import org.bukkit.inventory.ImItemStack; +import org.jdom2.Element; +import tc.oc.api.docs.SemanticVersion; +import tc.oc.commons.bukkit.localization.MessageTemplate; +import tc.oc.commons.core.formatting.StringUtils; +import tc.oc.commons.core.util.Comparables; +import tc.oc.pgm.classes.ClassModule; +import tc.oc.pgm.classes.PlayerClass; +import tc.oc.pgm.features.FeatureDefinitionContext; +import tc.oc.pgm.features.FeatureDefinitionParser; +import tc.oc.pgm.features.FeatureParser; +import tc.oc.pgm.features.MagicMethodFeatureParser; +import tc.oc.pgm.filters.Filter; +import tc.oc.pgm.filters.matcher.CauseFilter; +import tc.oc.pgm.filters.matcher.StaticFilter; +import tc.oc.pgm.filters.matcher.block.MaterialFilter; +import tc.oc.pgm.filters.matcher.block.StructuralLoadFilter; +import tc.oc.pgm.filters.matcher.block.VoidFilter; +import tc.oc.pgm.filters.matcher.damage.AttackerFilter; +import tc.oc.pgm.filters.matcher.damage.DamagerFilter; +import tc.oc.pgm.filters.matcher.damage.RelationFilter; +import tc.oc.pgm.filters.matcher.damage.VictimFilter; +import tc.oc.pgm.filters.matcher.entity.EntityTypeFilter; +import tc.oc.pgm.filters.matcher.entity.SpawnReasonFilter; +import tc.oc.pgm.filters.matcher.match.FlagStateFilter; +import tc.oc.pgm.filters.matcher.match.LegacyRandomFilter; +import tc.oc.pgm.filters.matcher.match.MatchMutationFilter; +import tc.oc.pgm.filters.matcher.match.MatchStateFilter; +import tc.oc.pgm.filters.matcher.match.MonostableFilter; +import tc.oc.pgm.filters.matcher.match.PlayerCountFilter; +import tc.oc.pgm.filters.matcher.match.RandomFilter; +import tc.oc.pgm.filters.matcher.party.CompetitorFilter; +import tc.oc.pgm.filters.matcher.party.GoalFilter; +import tc.oc.pgm.filters.matcher.party.RankFilter; +import tc.oc.pgm.filters.matcher.party.ScoreFilter; +import tc.oc.pgm.filters.matcher.party.TeamFilter; +import tc.oc.pgm.filters.matcher.player.AttributeFilter; +import tc.oc.pgm.filters.matcher.player.CanFlyFilter; +import tc.oc.pgm.filters.matcher.player.CarryingFlagFilter; +import tc.oc.pgm.filters.matcher.player.CarryingItemFilter; +import tc.oc.pgm.filters.matcher.player.HoldingItemFilter; +import tc.oc.pgm.filters.matcher.player.KillStreakFilter; +import tc.oc.pgm.filters.matcher.player.ParticipatingFilter; +import tc.oc.pgm.filters.matcher.player.PlayerClassFilter; +import tc.oc.pgm.filters.matcher.player.PoseFilter; +import tc.oc.pgm.filters.matcher.player.WearingItemFilter; +import tc.oc.pgm.filters.operator.AllFilter; +import tc.oc.pgm.filters.operator.AnyFilter; +import tc.oc.pgm.filters.operator.FallthroughFilter; +import tc.oc.pgm.filters.operator.InverseFilter; +import tc.oc.pgm.filters.operator.OneFilter; +import tc.oc.pgm.filters.operator.SameTeamFilter; +import tc.oc.pgm.filters.operator.TeamFilterAdapter; +import tc.oc.pgm.flag.FlagDefinition; +import tc.oc.pgm.flag.Post; +import tc.oc.pgm.flag.state.Captured; +import tc.oc.pgm.flag.state.Carried; +import tc.oc.pgm.flag.state.Dropped; +import tc.oc.pgm.flag.state.Returned; +import tc.oc.pgm.flag.state.State; +import tc.oc.pgm.goals.GoalDefinition; +import tc.oc.pgm.itemmeta.ItemModifier; +import tc.oc.pgm.kits.ItemParser; +import tc.oc.pgm.map.MapProto; +import tc.oc.pgm.map.ProtoVersions; +import tc.oc.pgm.match.MatchState; +import tc.oc.pgm.match.PlayerRelation; +import tc.oc.pgm.mutation.Mutation; +import tc.oc.pgm.teams.TeamFactory; +import tc.oc.pgm.utils.MethodParser; +import tc.oc.pgm.utils.XMLUtils; +import tc.oc.pgm.xml.InvalidXMLException; +import tc.oc.pgm.xml.Node; +import tc.oc.pgm.xml.parser.Parser; +import tc.oc.pgm.xml.property.MessageTemplateProperty; +import tc.oc.pgm.xml.property.PropertyBuilderFactory; + +import static tc.oc.commons.core.exception.LambdaExceptionUtils.rethrowFunction; + +public class FilterDefinitionParser extends MagicMethodFeatureParser implements FeatureDefinitionParser { + + @Inject protected @MapProto SemanticVersion proto; + @Inject protected FeatureDefinitionContext features; + @Inject protected ItemParser itemParser; + @Inject protected Parser attributeParser; + @Inject protected PropertyBuilderFactory messageTemplates; + @Inject protected CauseFilter.Factory causeFilters; + @Inject protected FilterParser filterParser; + @Inject protected FeatureParser teamParser; + @Inject protected Provider classModule; + @Inject protected ItemModifier itemModifier; + + @MethodParser("allow") + public Filter parseAllow(Element el) throws InvalidXMLException { + return new FallthroughFilter(Filter.QueryResponse.ALLOW, filterParser.parseChild(el)); + } + + @MethodParser("deny") + public Filter parseDeny(Element el) throws InvalidXMLException { + return new FallthroughFilter(Filter.QueryResponse.DENY, filterParser.parseChild(el)); + } + + @MethodParser("always") + public Filter parseAlways(Element el) { + return new StaticFilter(Filter.QueryResponse.ALLOW); + } + + @MethodParser("never") + public Filter parseNever(Element el) { + return new StaticFilter(Filter.QueryResponse.DENY); + } + + @MethodParser("any") + public Filter parseAny(Element el) throws InvalidXMLException { + return new AnyFilter(filterParser.parseChildList(el)); + } + + @MethodParser("all") + public Filter parseAll(Element el) throws InvalidXMLException { + return new AllFilter(filterParser.parseChildList(el)); + } + + @MethodParser("one") + public Filter parseOne(Element el) throws InvalidXMLException { + return new OneFilter(filterParser.parseChildList(el)); + } + + @MethodParser("not") + public Filter parseNot(Element el) throws InvalidXMLException { + return new InverseFilter(filterParser.parseChild(el)); + } + + @MethodParser("team") + public TeamFilter parseTeam(Element el) throws InvalidXMLException { + return new TeamFilter(teamParser.parseReference(Node.of(el))); + } + + @MethodParser("same-team") + public SameTeamFilter parseSameTeam(Element el) throws InvalidXMLException { + return new SameTeamFilter(filterParser.parseChild(el)); + } + + @MethodParser("attacker") + public AttackerFilter parseAttacker(Element el) throws InvalidXMLException { + return new AttackerFilter(filterParser.parseChild(el)); + } + + @MethodParser("victim") + public VictimFilter parseVictim(Element el) throws InvalidXMLException { + return new VictimFilter(filterParser.parseChild(el)); + } + + @MethodParser + public Filter damager(Element el) throws InvalidXMLException { + return new DamagerFilter(filterParser.parseChild(el)); + } + + @MethodParser("class") + public PlayerClassFilter parseClass(Element el) throws InvalidXMLException { + final PlayerClass playerClass = StringUtils.bestFuzzyMatch(el.getTextNormalize(), classModule.get().getPlayerClasses(), 0.9); + if (playerClass == null) { + throw new InvalidXMLException("Could not find player-class: " + el.getTextNormalize(), el); + } + + return new PlayerClassFilter(playerClass); + } + + @MethodParser("material") + public MaterialFilter parseMaterial(Element el) throws InvalidXMLException { + return new MaterialFilter(XMLUtils.parseMaterialPattern(el)); + } + + @MethodParser("void") + public VoidFilter parseVoid(Element el) throws InvalidXMLException { + return new VoidFilter(); + } + + @MethodParser("entity") + public EntityTypeFilter parseEntity(Element el) throws InvalidXMLException { + return new EntityTypeFilter(XMLUtils.parseEnum(el, EntityType.class, "entity type")); + } + + @MethodParser("mob") + public EntityTypeFilter parseMob(Element el) throws InvalidXMLException { + EntityTypeFilter matcher = this.parseEntity(el); + if(!LivingEntity.class.isAssignableFrom(matcher.getEntityType())) { + throw new InvalidXMLException("Unknown mob type: " + el.getTextNormalize(), el); + } + return matcher; + } + + @MethodParser("spawn") + public SpawnReasonFilter parseSpawnReason(Element el) throws InvalidXMLException { + return new SpawnReasonFilter(XMLUtils.parseEnum(new Node(el), CreatureSpawnEvent.SpawnReason.class, "spawn reason")); + } + + @MethodParser("kill-streak") + public KillStreakFilter parseKillStreak(Element el) throws InvalidXMLException { + boolean repeat = XMLUtils.parseBoolean(el.getAttribute("repeat"), false); + boolean persistent = XMLUtils.parseBoolean(el.getAttribute("persistent"), false); + Integer count = XMLUtils.parseNumber(el.getAttribute("count"), Integer.class, (Integer) null); + Integer min = XMLUtils.parseNumber(el.getAttribute("min"), Integer.class, (Integer) null); + Integer max = XMLUtils.parseNumber(el.getAttribute("max"), Integer.class, (Integer) null); + Range range; + + if(count != null) { + range = Range.singleton(count); + } else if(min == null) { + if(max == null) { + throw new InvalidXMLException("kill-streak filter must have a count, min, or max", el); + } else { + range = Range.atMost(max); + } + } else { + if(max == null) { + range = Range.atLeast(min); + } else { + range = Range.closed(min, max); + } + } + + if(repeat && !range.hasUpperBound()) { + throw new InvalidXMLException("repeating kill-streak filter must have a count or max", el); + } + + return new KillStreakFilter(range, repeat, persistent); + } + + @MethodParser("random") + public Filter parseRandom(Element el) throws InvalidXMLException { + Node node = new Node(el); + Range chance; + try { + chance = Range.closedOpen(0d, XMLUtils.parseNumber(node, Double.class)); + } catch(InvalidXMLException e) { + chance = XMLUtils.parseNumericRange(node, Double.class); + } + + Range valid = Range.closed(0d, 1d); + if (valid.encloses(chance)) { + return proto.isNoOlderThan(ProtoVersions.EVENT_QUERIES) ? new RandomFilter(chance) + : new LegacyRandomFilter(chance); + } else { + double lower = chance.hasLowerBound() ? chance.lowerEndpoint() : Double.NEGATIVE_INFINITY; + double upper = chance.hasUpperBound() ? chance.upperEndpoint() : Double.POSITIVE_INFINITY; + double invalid; + if(!valid.contains(lower)) { + invalid = lower; + } else { + invalid = upper; + } + + throw new InvalidXMLException("chance value (" + invalid + ") is not between 0 and 1", el); + } + } + + @MethodParser("grounded") + public Filter parseGrounded(Element el) throws InvalidXMLException { + return new PoseFilter(PoseFlag.GROUNDED); + } + + @MethodParser({"crouching", "sneaking"}) + public Filter parseCrouching(Element el) throws InvalidXMLException { + return new PoseFilter(PoseFlag.SNEAKING); + } + + @MethodParser("walking") + public Filter parseWalking(Element el) throws InvalidXMLException { + return new InverseFilter(new AnyFilter(new PoseFilter(PoseFlag.SNEAKING), + new PoseFilter(PoseFlag.SPRINTING))); + } + + @MethodParser("sprinting") + public Filter parseSprinting(Element el) throws InvalidXMLException { + return new PoseFilter(PoseFlag.SPRINTING); + } + + @MethodParser("gliding") + public Filter parseGlidingFilter(Element el) throws InvalidXMLException { + return new PoseFilter(PoseFlag.GLIDING); + } + + @MethodParser("flying") + public Filter parseFlying(Element el) throws InvalidXMLException { + return new PoseFilter(PoseFlag.FLYING); + } + + @MethodParser("can-fly") + public CanFlyFilter parseCanFly(Element el) throws InvalidXMLException { + return new CanFlyFilter(); + } + + private Filter parseExplicitTeam(Element el, CompetitorFilter filter) throws InvalidXMLException { + final boolean any = XMLUtils.parseBoolean(el.getAttribute("any"), false); + final Optional team = teamParser.property(el).optional(); + if(any && team.isPresent()) { + throw new InvalidXMLException("Cannot combine attributes 'team' and 'any'", el); + } + return any || team.isPresent() ? new TeamFilterAdapter(team, filter) + : filter; + } + + private GoalDefinition goalReference(Element el) throws InvalidXMLException { + return features.reference(new Node(el), GoalDefinition.class); + } + + private GoalFilter goalFilter(Element el) throws InvalidXMLException { + return new GoalFilter(goalReference(el)); + } + + @MethodParser("objective") + public Filter parseGoal(Element el) throws InvalidXMLException { + return parseExplicitTeam(el, goalFilter(el)); + } + + @MethodParser("completed") + public Filter parseCompleted(Element el) throws InvalidXMLException { + return new TeamFilterAdapter(Optional.empty(), goalFilter(el)); + } + + @MethodParser("captured") + public Filter parseCaptured(Element el) throws InvalidXMLException { + final GoalFilter goal = goalFilter(el); + final Optional team = teamParser.property(el).optional(); + return team.isPresent() ? new TeamFilterAdapter(team, goal) + : goal; + } + + @MethodParser("rank") + public Filter parseRankFilter(Element el) throws InvalidXMLException { + return parseExplicitTeam(el, new RankFilter(XMLUtils.parseNumericRange(new Node(el), Integer.class))); + } + + @MethodParser("score") + public Filter parseScoreFilter(Element el) throws InvalidXMLException { + return parseExplicitTeam(el, new ScoreFilter(XMLUtils.parseNumericRange(new Node(el), Integer.class))); + } + + protected FlagStateFilter parseFlagState(Element el, Class state) throws InvalidXMLException { + return new FlagStateFilter(features.reference(new Node(el), FlagDefinition.class), + Node.tryAttr(el, "post").map(rethrowFunction(attr -> features.reference(attr, Post.class))), + state); + } + + @MethodParser("flag-carried") + public FlagStateFilter parseFlagCarried(Element el) throws InvalidXMLException { + return this.parseFlagState(el, Carried.class); + } + + @MethodParser("flag-dropped") + public FlagStateFilter parseFlagDropped(Element el) throws InvalidXMLException { + return this.parseFlagState(el, Dropped.class); + } + + @MethodParser("flag-returned") + public FlagStateFilter parseFlagReturned(Element el) throws InvalidXMLException { + return this.parseFlagState(el, Returned.class); + } + + @MethodParser("flag-captured") + public FlagStateFilter parseFlagCaptured(Element el) throws InvalidXMLException { + return this.parseFlagState(el, Captured.class); + } + + @MethodParser("carrying-flag") + public CarryingFlagFilter parseCarryingFlag(Element el) throws InvalidXMLException { + return new CarryingFlagFilter(features.reference(new Node(el), FlagDefinition.class)); + } + + @MethodParser("cause") + public CauseFilter parseCause(Element el) throws InvalidXMLException { + return causeFilters.create(XMLUtils.parseEnum(el, CauseFilter.Cause.class, "cause filter")); + } + + @MethodParser("relation") + public RelationFilter parseRelation(Element el) throws InvalidXMLException { + return new RelationFilter(XMLUtils.parseEnum(el, PlayerRelation.class, "player relation filter")); + } + + private ImItemStack parseFilterItem(Element el) throws InvalidXMLException { + return itemModifier.modify(itemParser.parseRequiredItem(el)).immutableCopy(); + } + + @MethodParser("carrying") + public CarryingItemFilter parseHasItem(Element el) throws InvalidXMLException { + return new CarryingItemFilter(parseFilterItem(el)); + } + + @MethodParser("holding") + public HoldingItemFilter parseHolding(Element el) throws InvalidXMLException { + return new HoldingItemFilter(parseFilterItem(el)); + } + + @MethodParser("wearing") + public WearingItemFilter parseWearingItem(Element el) throws InvalidXMLException { + return new WearingItemFilter(parseFilterItem(el)); + } + + @MethodParser("structural-load") + public StructuralLoadFilter parseStructuralLoad(Element el) throws InvalidXMLException { + return new StructuralLoadFilter(XMLUtils.parseNumber(el, Integer.class)); + } + + @MethodParser("time") + public Filter parseTimeFilter(Element el) throws InvalidXMLException { + final Duration duration = XMLUtils.parseDuration(el, (Duration) null); + if(Comparables.greaterThan(duration, Duration.ZERO)) { + return new AllFilter( + MatchStateFilter.started(), + new MonostableFilter( + duration, + MatchStateFilter.running(), + Optional.empty() + ).not() + ); + } else { + return new MatchStateFilter(MatchState.Running, MatchState.Finished); + } + } + + @MethodParser("countdown") + public Filter parseCountdownFilter(Element el) throws InvalidXMLException { + final Duration duration = XMLUtils.parseDuration(el, "duration").required(); + if(Comparables.greaterThan(duration, Duration.ZERO)) { + return new MonostableFilter(duration, + filterParser.parseReferenceOrChild(el), + messageTemplates.property(el, "message") + .placeholders(Range.closed(0, 1)) + .optional()); + } else { + return new StaticFilter(Filter.QueryResponse.DENY); + } + } + + @MethodParser("mutation") + public MatchMutationFilter parseMatchMutation(Element el) throws InvalidXMLException { + return new MatchMutationFilter(XMLUtils.parseEnum(el, Mutation.class, "match mutation")); + } + + @MethodParser("participating") + public Filter parseParticipating(Element el) throws InvalidXMLException { + return new ParticipatingFilter(true); + } + + @MethodParser("observing") + public Filter parseObserving(Element el) throws InvalidXMLException { + return new ParticipatingFilter(false); + } + + @MethodParser("match-started") + public Filter parseMatchStarted(Element el) throws InvalidXMLException { + return new MatchStateFilter(MatchState.Running, MatchState.Finished); + } + + @MethodParser("match-running") + public Filter parseMatchRunning(Element el) throws InvalidXMLException { + return new MatchStateFilter(MatchState.Running); + } + + @MethodParser("match-finished") + public Filter parseMatchFinished(Element el) throws InvalidXMLException { + return new MatchStateFilter(MatchState.Finished); + } + + @MethodParser("players") + public Filter parsePlayerCount(Element el) throws InvalidXMLException { + return new PlayerCountFilter(filterParser.parseReferenceOrChild(el), + XMLUtils.parseNumericRange(el, Integer.class, Range.atLeast(1)), + XMLUtils.parseBoolean(el, "participants").optional(true), + XMLUtils.parseBoolean(el, "observers").optional(false)); + } + + @MethodParser + public Filter attribute(Element el) throws InvalidXMLException { + final Node node = Node.of(el); + return new AttributeFilter(attributeParser.parse(node), + XMLUtils.parseNumericRange(el, Double.class)); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/parser/FilterParser.java b/PGM/src/main/java/tc/oc/pgm/filters/parser/FilterParser.java new file mode 100644 index 0000000..bbac435 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/parser/FilterParser.java @@ -0,0 +1,103 @@ +package tc.oc.pgm.filters.parser; + +import javax.inject.Inject; + +import org.jdom2.Document; +import org.jdom2.Element; +import tc.oc.pgm.features.LegacyFeatureParser; +import tc.oc.pgm.filters.Filter; +import tc.oc.pgm.filters.matcher.StaticFilter; +import tc.oc.pgm.filters.query.IQuery; +import tc.oc.pgm.map.MapModule; +import tc.oc.pgm.map.MapRootParser; +import tc.oc.pgm.regions.RegionParser; +import tc.oc.pgm.utils.XMLUtils; +import tc.oc.pgm.xml.InvalidXMLException; +import tc.oc.pgm.xml.Node; +import tc.oc.pgm.xml.UnrecognizedXMLException; +import tc.oc.pgm.xml.validate.Validation; + +import static tc.oc.commons.core.exception.LambdaExceptionUtils.rethrowConsumer; + +public class FilterParser extends LegacyFeatureParser implements MapModule, MapRootParser { + + @Inject protected Document xml; + @Inject protected RegionParser regionParser; + @Inject protected DynamicFilterValidation dynamicFilterValidation; + + @Override + public void parse() throws InvalidXMLException { + defineBuiltInFilters(); + parseTopLevelFilters(); + } + + protected void defineBuiltInFilters() throws InvalidXMLException { + features.define("always", new StaticFilter(Filter.QueryResponse.ALLOW)); + features.define("never", new StaticFilter(Filter.QueryResponse.DENY)); + } + + protected void parseTopLevelFilters() throws InvalidXMLException { + // Modern proto treats and the same + for(Element el : XMLUtils.getChildren(xml.getRootElement(), "filters", "regions")) { + parseChildren(el).count(); + } + } + + @Override + protected boolean canIgnore(Element el) throws InvalidXMLException { + return "apply".equals(el.getName()) || super.canIgnore(el); + } + + @Override + public boolean isParseable(Element el) throws InvalidXMLException { + return super.isParseable(el) || regionParser.isParseable(el); + } + + @Override + public Filter parseElement(Element el) throws InvalidXMLException { + // If we find something unparseable, try parsing it as a region before giving up + if(super.isParseable(el)) { + return super.parseElement(el); + } else if(regionParser.isParseable(el)) { + return regionParser.parseElement(el); + } else { + throw new UnrecognizedXMLException(propertyName(), el); + } + } + + @Override + public FilterPropertyBuilder property(Element element) { + return property(element, propertyName()); + } + + @Override + public FilterPropertyBuilder property(Element el, String name) { + return new FilterPropertyBuilder(el, name); + } + + public class FilterPropertyBuilder extends PropertyBuilder { + public FilterPropertyBuilder(Element element, String name) { + super(element, name); + } + + public FilterPropertyBuilder respondsTo(Class queryType) { + validate(RespondsToQueryValidation.get(queryType)); + return this; + } + + public FilterPropertyBuilder dynamic() { + validateTree(dynamicFilterValidation); + return this; + } + + public FilterPropertyBuilder validateTree(Validation validation) { + validate((filter, node) -> applyValidationToTree(filter, node, validation)); + return this; + } + } + + private static void applyValidationToTree(Filter filter, Node node, Validation validation) throws InvalidXMLException { + validation.validate(filter, node); + filter.dependencies(Filter.class).forEach(rethrowConsumer(dep -> applyValidationToTree(dep, node, validation))); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/parser/LegacyFilterDefinitionParser.java b/PGM/src/main/java/tc/oc/pgm/filters/parser/LegacyFilterDefinitionParser.java new file mode 100644 index 0000000..3f97096 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/parser/LegacyFilterDefinitionParser.java @@ -0,0 +1,68 @@ +package tc.oc.pgm.filters.parser; + +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import com.google.common.collect.ImmutableList; +import org.jdom2.Element; +import tc.oc.pgm.filters.Filter; +import tc.oc.pgm.filters.matcher.block.MaterialFilter; +import tc.oc.pgm.filters.operator.AnyFilter; +import tc.oc.pgm.filters.operator.FilterNode; +import tc.oc.pgm.filters.operator.InverseFilter; +import tc.oc.pgm.utils.MaterialPattern; +import tc.oc.pgm.utils.MethodParser; +import tc.oc.pgm.utils.XMLUtils; +import tc.oc.pgm.xml.InvalidXMLException; +import tc.oc.pgm.xml.Node; + +import static tc.oc.commons.core.exception.LambdaExceptionUtils.rethrowFunction; + +public class LegacyFilterDefinitionParser extends FilterDefinitionParser { + + protected List parseGrandchildren(Element parent, String childName) throws InvalidXMLException { + return parent.getChildren(childName) + .stream() + .flatMap(rethrowFunction(filterParser::parseChildren)) + .collect(Collectors.toList()); + } + + protected List parseParents(Element el) throws InvalidXMLException { + return Node.tryAttr(el, "parents") + .map(attr -> Stream.of(attr.getValueNormalize().split("\\s")) + .map(rethrowFunction(name -> filterParser.parseReference(attr, name))) + .collect(tc.oc.commons.core.stream.Collectors.toImmutableList())) + .orElse(ImmutableList.of()); + } + + // Deprecated uses of + @MethodParser("filter") + public Filter parseFilter(Element el) throws InvalidXMLException { + if(el.getAttribute("parents") != null || el.getChild("allow") != null || el.getChild("deny") != null) { + // A weird node thing + return new FilterNode(parseParents(el), + parseGrandchildren(el, "allow"), + parseGrandchildren(el, "deny")); + } else { + // An alias for (is this actually used anywhere?) + return parseAll(el); + } + } + + // Deprecated alias for removed to avoid conflict with region + @MethodParser("block") + public Filter parseBlock(Element el) throws InvalidXMLException { + MaterialPattern pattern = XMLUtils.parseMaterialPattern(el); + if(!pattern.getMaterial().isBlock()) { + throw new InvalidXMLException("Material is not a block", el); + } + return new MaterialFilter(pattern); + } + + // Deprecated syntax with multiple child filters + @MethodParser("not") + public Filter parseNot(Element el) throws InvalidXMLException { + return new InverseFilter(new AnyFilter(filterParser.parseChildList(el))); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/parser/LegacyFilterParser.java b/PGM/src/main/java/tc/oc/pgm/filters/parser/LegacyFilterParser.java new file mode 100644 index 0000000..eb5cc53 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/parser/LegacyFilterParser.java @@ -0,0 +1,64 @@ +package tc.oc.pgm.filters.parser; + +import org.bukkit.entity.LivingEntity; +import org.jdom2.Element; +import tc.oc.pgm.filters.Filter; +import tc.oc.pgm.filters.matcher.QueryTypeFilter; +import tc.oc.pgm.filters.matcher.StaticFilter; +import tc.oc.pgm.filters.matcher.entity.EntityTypeFilter; +import tc.oc.pgm.filters.matcher.entity.LegacyWorldFilter; +import tc.oc.pgm.filters.operator.FallthroughFilter; +import tc.oc.pgm.filters.query.IBlockQuery; +import tc.oc.pgm.filters.query.IEntitySpawnQuery; +import tc.oc.pgm.filters.query.IEntityTypeQuery; +import tc.oc.pgm.filters.query.IPlayerQuery; +import tc.oc.pgm.xml.InvalidXMLException; + +/** + * For proto < 1.4 + */ +public class LegacyFilterParser extends FilterParser { + + @Override + protected void defineBuiltInFilters() throws InvalidXMLException { + addDefault("allow-all", new StaticFilter(Filter.QueryResponse.ALLOW)); + addDefault("deny-all", new StaticFilter(Filter.QueryResponse.DENY)); + addDefaultPair("players", new QueryTypeFilter(IPlayerQuery.class)); + addDefaultPair("blocks", new QueryTypeFilter(IBlockQuery.class)); + addDefaultPair("world", new LegacyWorldFilter()); + addDefaultPair("spawns", new QueryTypeFilter(IEntitySpawnQuery.class)); + addDefaultPair("entities", new QueryTypeFilter(IEntityTypeQuery.class)); + addDefaultPair("mobs", new EntityTypeFilter(LivingEntity.class)); + } + + private void addDefaultPair(String name, Filter filter) throws InvalidXMLException { + addDefault("allow-" + name, new FallthroughFilter(Filter.QueryResponse.ALLOW, filter)); + addDefault("deny-" + name, new FallthroughFilter(Filter.QueryResponse.DENY, filter)); + } + + private void addDefault(String name, Filter filter) throws InvalidXMLException { + features.define(mangleId(name), filter); + } + + @Override + protected void parseTopLevelFilters() throws InvalidXMLException { + // Legacy proto seperates filters and regions. The only reason + // this matters is that is ambiguous - it's both a region, + // and a deprecated alias for . + for(Element el : xml.getRootElement().getChildren("filters")) { + parseChildren(el).count(); + } + for(Element el : xml.getRootElement().getChildren("regions")) { + regionParser.parseChildren(el).count(); + } + } + + @Override + public boolean isReference(Element el) { + // References look different, and are a lot harder to distinguish from other things + return el.getName().equalsIgnoreCase("filter") && + el.getChildren().isEmpty() && + el.getAttribute("parents") == null && + el.getAttribute("name") != null; + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/parser/RespondsToQueryValidation.java b/PGM/src/main/java/tc/oc/pgm/filters/parser/RespondsToQueryValidation.java new file mode 100644 index 0000000..51a3638 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/parser/RespondsToQueryValidation.java @@ -0,0 +1,31 @@ +package tc.oc.pgm.filters.parser; + +import com.google.common.cache.LoadingCache; +import tc.oc.commons.core.util.CacheUtils; +import tc.oc.pgm.filters.Filter; +import tc.oc.pgm.filters.FilterTypeException; +import tc.oc.pgm.filters.query.IQuery; +import tc.oc.pgm.xml.InvalidXMLException; +import tc.oc.pgm.xml.Node; +import tc.oc.pgm.xml.validate.Validation; + +public class RespondsToQueryValidation implements Validation { + + private static final LoadingCache, RespondsToQueryValidation> CACHE = CacheUtils.newCache(RespondsToQueryValidation::new); + public static RespondsToQueryValidation get(Class queryType) { return CACHE.getUnchecked(queryType); } + + private final Class queryType; + + protected RespondsToQueryValidation(Class queryType) { + this.queryType = queryType; + } + + @Override + public void validate(Filter filter, Node node) throws InvalidXMLException { + try { + filter.assertRespondsTo(queryType); + } catch(FilterTypeException e) { + throw new InvalidXMLException(e.getMessage(), node); + } + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/query/BlockEventQuery.java b/PGM/src/main/java/tc/oc/pgm/filters/query/BlockEventQuery.java new file mode 100644 index 0000000..87ba6c1 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/query/BlockEventQuery.java @@ -0,0 +1,39 @@ +package tc.oc.pgm.filters.query; + +import java.util.Optional; +import javax.annotation.Nullable; + +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.BlockState; +import org.bukkit.event.Event; + +import static com.google.common.base.Preconditions.checkNotNull; + +public class BlockEventQuery extends BlockQuery implements IBlockEventQuery { + + private final Event event; + + public BlockEventQuery(Event event, World world, int x, int y, int z, @Nullable BlockState block) { + super(world, x, y, z, block); + this.event = checkNotNull(event); + } + + public BlockEventQuery(Event event, BlockState block) { + this(event, block.getWorld(), block.getX(), block.getY(), block.getZ(), block); + } + + public BlockEventQuery(Event event, Block block) { + this(event, block.getWorld(), block.getX(), block.getY(), block.getZ(), null); + } + + public static IBlockQuery of(BlockState block, Optional event) { + return event.map(e -> new BlockEventQuery(e, block)) + .orElseGet(() -> new BlockQuery(block)); + } + + @Override + public Event getEvent() { + return event; + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/query/BlockQuery.java b/PGM/src/main/java/tc/oc/pgm/filters/query/BlockQuery.java new file mode 100644 index 0000000..935e888 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/query/BlockQuery.java @@ -0,0 +1,84 @@ +package tc.oc.pgm.filters.query; + +import javax.annotation.Nullable; + +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.BlockState; +import org.bukkit.material.MaterialData; +import org.bukkit.util.ImVector; +import tc.oc.pgm.PGM; +import tc.oc.pgm.match.Match; + +import static com.google.common.base.Preconditions.checkNotNull; + +/** + * A block query is canonically defined by a {@link World} and a set of integer block coordinates. + * The other properties are created lazily, to gain a bit of efficiency when querying filters that + * don't check them. + */ +public class BlockQuery implements IBlockQuery { + + private final World world; + private final Match match; + private final int x, y, z; + private @Nullable BlockState block; + private @Nullable Location location; + private @Nullable ImVector blockCenter; + private @Nullable MaterialData material; + + protected BlockQuery(World world, int x, int y, int z, @Nullable BlockState block) { + this.world = checkNotNull(world); + this.match = PGM.getMatchManager().getMatch(world); + this.x = x; + this.y = y; + this.z = z; + this.block = block; + } + + public BlockQuery(Block block) { + this(block.getWorld(), block.getX(), block.getY(), block.getZ(), null); + } + + public BlockQuery(BlockState block) { + this(block.getWorld(), block.getX(), block.getY(), block.getZ(), block); + } + + @Override + public Match getMatch() { + return match; + } + + @Override + public BlockState getBlock() { + if(block == null) { + block = world.getBlockAt(x, y, z).getState(); + } + return block; + } + + @Override + public Location getLocation() { + if(location == null) { + location = new Location(world, x, y, z); + } + return location; + } + + @Override + public ImVector blockCenter() { + if(blockCenter == null) { + blockCenter = ImVector.centerOf(x, y, z); + } + return blockCenter; + } + + @Override + public MaterialData getMaterial() { + if(material == null) { + material = getBlock().getMaterialData(); + } + return material; + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/query/DamageQuery.java b/PGM/src/main/java/tc/oc/pgm/filters/query/DamageQuery.java new file mode 100644 index 0000000..921b094 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/query/DamageQuery.java @@ -0,0 +1,38 @@ +package tc.oc.pgm.filters.query; + +import javax.annotation.Nullable; + +import org.bukkit.event.Event; +import tc.oc.pgm.tracker.damage.DamageInfo; + +import static com.google.common.base.Preconditions.checkNotNull; + +public class DamageQuery extends PlayerEventQuery implements IDamageQuery { + + private final IPlayerQuery victim; + private final DamageInfo damageInfo; + + protected DamageQuery(IPlayerQuery player, Event event, IPlayerQuery victim, DamageInfo damageInfo) { + super(player, event); + this.damageInfo = checkNotNull(damageInfo); + this.victim = checkNotNull(victim); + } + + public static DamageQuery victimDefault(@Nullable Event event, IPlayerQuery victim, DamageInfo damageInfo) { + return new DamageQuery(victim, event, victim, damageInfo); + } + + public static DamageQuery attackerDefault(@Nullable Event event, IPlayerQuery victim, DamageInfo damageInfo) { + return new DamageQuery(damageInfo.getAttacker(), event, victim, damageInfo); + } + + @Override + public IPlayerQuery getVictim() { + return victim; + } + + @Override + public DamageInfo getDamageInfo() { + return damageInfo; + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/query/EntityQuery.java b/PGM/src/main/java/tc/oc/pgm/filters/query/EntityQuery.java new file mode 100644 index 0000000..524d96e --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/query/EntityQuery.java @@ -0,0 +1,34 @@ +package tc.oc.pgm.filters.query; + +import org.bukkit.EntityLocation; +import org.bukkit.entity.Entity; +import tc.oc.pgm.PGM; +import tc.oc.pgm.match.Match; + +import static com.google.common.base.Preconditions.checkNotNull; + +public class EntityQuery implements IEntityQuery { + + private final Entity entity; + private final Match match; + + public EntityQuery(Entity entity) { + this.entity = checkNotNull(entity); + this.match = PGM.getMatchManager().getMatch(entity.getWorld()); + } + + @Override + public EntityLocation getEntityLocation() { + return entity.getEntityLocation(); + } + + @Override + public Class getEntityType() { + return entity.getClass(); + } + + @Override + public Match getMatch() { + return match; + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/query/EntitySpawnQuery.java b/PGM/src/main/java/tc/oc/pgm/filters/query/EntitySpawnQuery.java new file mode 100644 index 0000000..2ceb393 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/query/EntitySpawnQuery.java @@ -0,0 +1,34 @@ +package tc.oc.pgm.filters.query; + +import org.bukkit.entity.Entity; +import org.bukkit.event.Event; +import org.bukkit.event.entity.CreatureSpawnEvent; + +import static com.google.common.base.Preconditions.checkNotNull; + +public class EntitySpawnQuery extends EntityQuery implements IEntitySpawnQuery { + + private final Event event; + private final CreatureSpawnEvent.SpawnReason spawnReason; + + public EntitySpawnQuery(Event event, Entity entity, CreatureSpawnEvent.SpawnReason spawnReason) { + super(entity); + this.event = checkNotNull(event); + this.spawnReason = checkNotNull(spawnReason); + } + + @Override + public Event getEvent() { + return event; + } + + @Override + public CreatureSpawnEvent.SpawnReason getSpawnReason() { + return spawnReason; + } + + @Override + public int randomSeed() { + return IEntitySpawnQuery.super.randomSeed(); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/query/ForwardingPlayerQuery.java b/PGM/src/main/java/tc/oc/pgm/filters/query/ForwardingPlayerQuery.java new file mode 100644 index 0000000..d0e8a33 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/query/ForwardingPlayerQuery.java @@ -0,0 +1,51 @@ +package tc.oc.pgm.filters.query; + +import java.util.Optional; + +import org.bukkit.EntityLocation; +import tc.oc.api.docs.PlayerId; +import tc.oc.pgm.match.Match; +import tc.oc.pgm.match.MatchPlayer; +import tc.oc.pgm.match.MatchPlayerState; +import tc.oc.pgm.match.ParticipantState; +import tc.oc.pgm.match.Party; + +public interface ForwardingPlayerQuery extends IPlayerQuery { + + IPlayerQuery playerQuery(); + + @Override + default Match getMatch() { + return playerQuery().getMatch(); + } + + @Override + default Party getParty() { + return playerQuery().getParty(); + } + + @Override + default PlayerId getPlayerId() { + return playerQuery().getPlayerId(); + } + + @Override + default MatchPlayerState playerState() { + return playerQuery().playerState(); + } + + @Override + default Optional participantState() { + return playerQuery().participantState(); + } + + @Override + default Optional onlinePlayer() { + return playerQuery().onlinePlayer(); + } + + @Override + default EntityLocation getEntityLocation() { + return playerQuery().getEntityLocation(); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/query/IBlockEventQuery.java b/PGM/src/main/java/tc/oc/pgm/filters/query/IBlockEventQuery.java new file mode 100644 index 0000000..5a4acfb --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/query/IBlockEventQuery.java @@ -0,0 +1,11 @@ +package tc.oc.pgm.filters.query; + +import java.util.Objects; + +public interface IBlockEventQuery extends IBlockQuery, IEventQuery { + + @Override + default int randomSeed() { + return Objects.hash(getEvent(), getBlock()); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/query/IBlockQuery.java b/PGM/src/main/java/tc/oc/pgm/filters/query/IBlockQuery.java new file mode 100644 index 0000000..ddce138 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/query/IBlockQuery.java @@ -0,0 +1,13 @@ +package tc.oc.pgm.filters.query; + +import org.bukkit.block.BlockState; + +public interface IBlockQuery extends IMatchQuery, ILocationQuery, IMaterialQuery { + + BlockState getBlock(); + + @Override + default int randomSeed() { + return getBlock().hashCode(); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/query/IDamageQuery.java b/PGM/src/main/java/tc/oc/pgm/filters/query/IDamageQuery.java new file mode 100644 index 0000000..50aec1a --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/query/IDamageQuery.java @@ -0,0 +1,10 @@ +package tc.oc.pgm.filters.query; + +import tc.oc.pgm.tracker.damage.DamageInfo; + +public interface IDamageQuery extends IPlayerEventQuery { + + IPlayerQuery getVictim(); + + DamageInfo getDamageInfo(); +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/query/IEntityEventQuery.java b/PGM/src/main/java/tc/oc/pgm/filters/query/IEntityEventQuery.java new file mode 100644 index 0000000..c9e3cc5 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/query/IEntityEventQuery.java @@ -0,0 +1,10 @@ +package tc.oc.pgm.filters.query; + +import java.util.Objects; + +public interface IEntityEventQuery extends IEventQuery, IEntityTypeQuery { + @Override + default int randomSeed() { + return Objects.hash(getEvent(), getEntityType()); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/query/IEntityQuery.java b/PGM/src/main/java/tc/oc/pgm/filters/query/IEntityQuery.java new file mode 100644 index 0000000..b05b0d7 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/query/IEntityQuery.java @@ -0,0 +1,28 @@ +package tc.oc.pgm.filters.query; + +import java.util.Objects; +import java.util.Set; + +import org.bukkit.EntityLocation; +import org.bukkit.Location; +import org.bukkit.PoseFlag; + +public interface IEntityQuery extends IEntityTypeQuery, ILocationQuery, IPoseQuery { + + EntityLocation getEntityLocation(); + + @Override + default Location getLocation() { + return getEntityLocation(); + } + + @Override + default Set getPose() { + return getEntityLocation().poseFlags(); + } + + @Override + default int randomSeed() { + return Objects.hash(getEntityType(), getEntityLocation(), getPose()); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/query/IEntitySpawnQuery.java b/PGM/src/main/java/tc/oc/pgm/filters/query/IEntitySpawnQuery.java new file mode 100644 index 0000000..57fd4b1 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/query/IEntitySpawnQuery.java @@ -0,0 +1,15 @@ +package tc.oc.pgm.filters.query; + +import java.util.Objects; + +import org.bukkit.event.entity.CreatureSpawnEvent; + +public interface IEntitySpawnQuery extends IEntityTypeQuery, IEventQuery { + + CreatureSpawnEvent.SpawnReason getSpawnReason(); + + @Override + default int randomSeed() { + return Objects.hash(getEvent(), getEntityType(), getSpawnReason()); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/query/IEntityTypeQuery.java b/PGM/src/main/java/tc/oc/pgm/filters/query/IEntityTypeQuery.java new file mode 100644 index 0000000..bb437e7 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/query/IEntityTypeQuery.java @@ -0,0 +1,13 @@ +package tc.oc.pgm.filters.query; + +import org.bukkit.entity.Entity; + +public interface IEntityTypeQuery extends IMatchQuery { + + Class getEntityType(); + + @Override + default int randomSeed() { + return getEntityType().hashCode(); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/query/IEventQuery.java b/PGM/src/main/java/tc/oc/pgm/filters/query/IEventQuery.java new file mode 100644 index 0000000..d83f61e --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/query/IEventQuery.java @@ -0,0 +1,13 @@ +package tc.oc.pgm.filters.query; + +import org.bukkit.event.Event; + +public interface IEventQuery extends ITransientQuery { + + Event getEvent(); + + @Override + default int randomSeed() { + return getEvent().hashCode(); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/query/ILocationQuery.java b/PGM/src/main/java/tc/oc/pgm/filters/query/ILocationQuery.java new file mode 100644 index 0000000..2d16694 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/query/ILocationQuery.java @@ -0,0 +1,18 @@ +package tc.oc.pgm.filters.query; + +import org.bukkit.Location; +import org.bukkit.util.ImVector; + +public interface ILocationQuery extends IMatchQuery { + + Location getLocation(); + + default ImVector blockCenter() { + return ImVector.copyOf(getLocation().position().blockCenter()); + } + + @Override + default int randomSeed() { + return getLocation().hashCode(); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/query/IMatchQuery.java b/PGM/src/main/java/tc/oc/pgm/filters/query/IMatchQuery.java new file mode 100644 index 0000000..f8c00f4 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/query/IMatchQuery.java @@ -0,0 +1,47 @@ +package tc.oc.pgm.filters.query; + +import java.util.Optional; +import java.util.stream.Stream; + +import java.time.Duration; +import tc.oc.api.docs.UserId; +import tc.oc.pgm.features.Feature; +import tc.oc.pgm.features.FeatureFactory; +import tc.oc.pgm.filters.Filterable; +import tc.oc.pgm.match.Competitor; +import tc.oc.pgm.match.Match; +import tc.oc.pgm.match.MatchModule; +import tc.oc.pgm.match.MatchPlayer; +import tc.oc.pgm.match.MatchState; + +public interface IMatchQuery extends IQuery { + + Match getMatch(); + + @Override + default int randomSeed() { + return getMatch().hashCode(); + } + + default > Optional filterable(Class type) { return Optional.of((Q) getMatch()); } + + default MatchState matchState() { return getMatch().matchState(); } + + default Duration runningTime() { return getMatch().runningTime(); } + + default Optional player(UserId userId) { return getMatch().player(userId); } + + default Stream players() { return getMatch().players(); } + + default Optional participant(UserId userId) { return getMatch().participant(userId); } + + default Stream participants() { return getMatch().participants(); } + + default Stream observers() { return getMatch().observers(); } + + default Stream competitors() { return getMatch().competitors(); } + + default Optional module(Class moduleType) { return getMatch().module(moduleType); } + + default > T feature(FeatureFactory factory) { return getMatch().feature(factory); } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/query/IMaterialQuery.java b/PGM/src/main/java/tc/oc/pgm/filters/query/IMaterialQuery.java new file mode 100644 index 0000000..47b770e --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/query/IMaterialQuery.java @@ -0,0 +1,13 @@ +package tc.oc.pgm.filters.query; + +import org.bukkit.material.MaterialData; + +public interface IMaterialQuery extends IQuery { + + MaterialData getMaterial(); + + @Override + default int randomSeed() { + return getMaterial().hashCode(); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/query/IPartyQuery.java b/PGM/src/main/java/tc/oc/pgm/filters/query/IPartyQuery.java new file mode 100644 index 0000000..ff2853c --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/query/IPartyQuery.java @@ -0,0 +1,35 @@ +package tc.oc.pgm.filters.query; + +import java.util.Optional; + +import tc.oc.commons.core.util.Optionals; +import tc.oc.pgm.filters.Filterable; +import tc.oc.pgm.match.Match; +import tc.oc.pgm.match.MatchState; +import tc.oc.pgm.match.Party; + +public interface IPartyQuery extends IMatchQuery { + + Party getParty(); + + @Override + default Match getMatch() { + return getParty().getMatch(); + } + + @Override + default int randomSeed() { + return getParty().hashCode(); + } + + @Override + default > Optional filterable(Class type) { + return Optionals.first(Optionals.cast(getParty(), type), + IMatchQuery.super.filterable(type)); + } + + default boolean isParticipating() { + // Check the MatchState through the query, so that PlayerMatchQuery can override it. + return matchState() == MatchState.Running && getParty().isParticipatingType(); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/query/IPlayerBlockEventQuery.java b/PGM/src/main/java/tc/oc/pgm/filters/query/IPlayerBlockEventQuery.java new file mode 100644 index 0000000..47d1ab2 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/query/IPlayerBlockEventQuery.java @@ -0,0 +1,11 @@ +package tc.oc.pgm.filters.query; + +import java.util.Objects; + +public interface IPlayerBlockEventQuery extends IPlayerEventQuery, IBlockEventQuery { + + @Override + default int randomSeed() { + return Objects.hash(getEvent(), getPlayerId(), getBlock()); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/query/IPlayerEventQuery.java b/PGM/src/main/java/tc/oc/pgm/filters/query/IPlayerEventQuery.java new file mode 100644 index 0000000..b3560e2 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/query/IPlayerEventQuery.java @@ -0,0 +1,11 @@ +package tc.oc.pgm.filters.query; + +import java.util.Objects; + +public interface IPlayerEventQuery extends IPlayerQuery, IEventQuery { + + @Override + default int randomSeed() { + return Objects.hash(getEvent(), getPlayerId()); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/query/IPlayerQuery.java b/PGM/src/main/java/tc/oc/pgm/filters/query/IPlayerQuery.java new file mode 100644 index 0000000..a8ff925 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/query/IPlayerQuery.java @@ -0,0 +1,46 @@ +package tc.oc.pgm.filters.query; + +import java.util.Optional; + +import org.bukkit.entity.Entity; +import org.bukkit.entity.Player; +import tc.oc.api.docs.PlayerId; +import tc.oc.commons.core.util.Optionals; +import tc.oc.pgm.filters.Filterable; +import tc.oc.pgm.match.MatchPlayer; +import tc.oc.pgm.match.MatchPlayerState; +import tc.oc.pgm.match.ParticipantState; + +/** + * A query for a player that may or may not be online or participating in the match. + */ +public interface IPlayerQuery extends IPartyQuery, IEntityQuery { + + PlayerId getPlayerId(); + + /** + * Return the {@link MatchPlayer} for this player if they are online, + * AND still a member of the party returned from {@link #getParty()}. + */ + Optional onlinePlayer(); + + MatchPlayerState playerState(); + + Optional participantState(); + + @Override + default > Optional filterable(Class type) { + return Optionals.first(Optionals.cast(onlinePlayer(), type), + IPartyQuery.super.filterable(type)); + } + + @Override + default Class getEntityType() { + return Player.class; + } + + @Override + default int randomSeed() { + return getPlayerId().hashCode(); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/query/IPoseQuery.java b/PGM/src/main/java/tc/oc/pgm/filters/query/IPoseQuery.java new file mode 100644 index 0000000..78b1dbb --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/query/IPoseQuery.java @@ -0,0 +1,15 @@ +package tc.oc.pgm.filters.query; + +import java.util.Set; + +import org.bukkit.PoseFlag; + +public interface IPoseQuery extends IMatchQuery { + + Set getPose(); + + @Override + default int randomSeed() { + return getPose().hashCode(); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/query/IQuery.java b/PGM/src/main/java/tc/oc/pgm/filters/query/IQuery.java new file mode 100644 index 0000000..337532e --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/query/IQuery.java @@ -0,0 +1,9 @@ +package tc.oc.pgm.filters.query; + +public interface IQuery { + + /** + * A "random" number derived from the members of this query + */ + int randomSeed(); +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/query/ITransientQuery.java b/PGM/src/main/java/tc/oc/pgm/filters/query/ITransientQuery.java new file mode 100644 index 0000000..bf73bfe --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/query/ITransientQuery.java @@ -0,0 +1,23 @@ +package tc.oc.pgm.filters.query; + +import tc.oc.commons.core.random.Entropy; +import tc.oc.commons.core.random.SaltedEntropy; +import tc.oc.pgm.match.Match; + +/** + * A time-sensitive query, representing some instantaneous match event + * + * @see IEventQuery + */ +public interface ITransientQuery extends IMatchQuery { + + /** + * Return a per-tick {@link Entropy} that generates values + * unique to this query. + * + * @see Match#entropyForTick() + */ + default Entropy entropy() { + return new SaltedEntropy(getMatch().entropyForTick(), randomSeed()); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/query/MaterialQuery.java b/PGM/src/main/java/tc/oc/pgm/filters/query/MaterialQuery.java new file mode 100644 index 0000000..b6b5711 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/query/MaterialQuery.java @@ -0,0 +1,27 @@ +package tc.oc.pgm.filters.query; + +import com.google.common.cache.LoadingCache; +import org.bukkit.material.MaterialData; +import tc.oc.commons.core.util.CacheUtils; + +import static com.google.common.base.Preconditions.checkNotNull; + +public class MaterialQuery implements IMaterialQuery { + + private final MaterialData material; + + private MaterialQuery(MaterialData material) { + this.material = checkNotNull(material); + } + + @Override + public MaterialData getMaterial() { + return material; + } + + private static final LoadingCache CACHE = CacheUtils.newCache(MaterialQuery::new); + + public static MaterialQuery of(MaterialData material) { + return CACHE.getUnchecked(material); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/query/PlayerBlockEventQuery.java b/PGM/src/main/java/tc/oc/pgm/filters/query/PlayerBlockEventQuery.java new file mode 100644 index 0000000..1a0b5e3 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/query/PlayerBlockEventQuery.java @@ -0,0 +1,50 @@ +package tc.oc.pgm.filters.query; + +import java.util.Optional; + +import org.bukkit.Location; +import org.bukkit.block.BlockState; +import org.bukkit.event.Event; +import org.bukkit.material.MaterialData; + +import static com.google.common.base.Preconditions.checkNotNull; + +public class PlayerBlockEventQuery extends PlayerEventQuery implements IPlayerBlockEventQuery { + + private final BlockState block; + + public PlayerBlockEventQuery(IPlayerQuery player, Event event, BlockState block) { + super(player, event); + this.block = checkNotNull(block); + } + + public static IBlockQuery of(BlockState block, Optional event, Optional player) { + return player.map(p -> new PlayerBlockEventQuery(p, event.get(), block)) + .orElseGet(() -> BlockEventQuery.of(block, event)); + } + + public static IBlockEventQuery of(BlockState block, Event event, Optional player) { + return player.map(p -> new PlayerBlockEventQuery(p, event, block)) + .orElseGet(() -> new BlockEventQuery(event, block)); + } + + @Override + public BlockState getBlock() { + return block; + } + + @Override + public Location getLocation() { + return block.getLocation(); + } + + @Override + public MaterialData getMaterial() { + return block.getMaterialData(); + } + + @Override + public int randomSeed() { + return IPlayerBlockEventQuery.super.randomSeed(); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/query/PlayerEventQuery.java b/PGM/src/main/java/tc/oc/pgm/filters/query/PlayerEventQuery.java new file mode 100644 index 0000000..ab6a74d --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/query/PlayerEventQuery.java @@ -0,0 +1,25 @@ +package tc.oc.pgm.filters.query; + +import org.bukkit.event.Event; + +import static com.google.common.base.Preconditions.checkNotNull; + +public class PlayerEventQuery extends TransientPlayerQuery implements IPlayerEventQuery { + + private final Event event; + + public PlayerEventQuery(IPlayerQuery player, Event event) { + super(player); + this.event = checkNotNull(event); + } + + @Override + public Event getEvent() { + return event; + } + + @Override + public int randomSeed() { + return IPlayerEventQuery.super.randomSeed(); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/query/PlayerQueryWithLocation.java b/PGM/src/main/java/tc/oc/pgm/filters/query/PlayerQueryWithLocation.java new file mode 100644 index 0000000..b6b738b --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/query/PlayerQueryWithLocation.java @@ -0,0 +1,33 @@ +package tc.oc.pgm.filters.query; + +import org.bukkit.EntityLocation; +import org.bukkit.Location; +import org.bukkit.util.ImVector; + +public class PlayerQueryWithLocation implements ForwardingPlayerQuery { + + private final IPlayerQuery player; + private final Location location; + private final ImVector blockCenter; + + public PlayerQueryWithLocation(IPlayerQuery player, Location location) { + this.player = player; + this.location = location; + this.blockCenter = ImVector.copyOf(location.position().blockCenter()); + } + + @Override + public IPlayerQuery playerQuery() { + return player; + } + + @Override + public EntityLocation getEntityLocation() { + return EntityLocation.coerce(location, playerQuery().getEntityLocation()); + } + + @Override + public ImVector blockCenter() { + return blockCenter; + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/query/TransientPlayerQuery.java b/PGM/src/main/java/tc/oc/pgm/filters/query/TransientPlayerQuery.java new file mode 100644 index 0000000..e8a6acb --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/query/TransientPlayerQuery.java @@ -0,0 +1,17 @@ +package tc.oc.pgm.filters.query; + +import tc.oc.pgm.match.MatchPlayerState; + +public class TransientPlayerQuery implements ForwardingPlayerQuery, ITransientQuery { + + private final MatchPlayerState playerState; + + public TransientPlayerQuery(IPlayerQuery delegate) { + this.playerState = delegate.playerState(); // Capture player state at event time + } + + @Override + public IPlayerQuery playerQuery() { + return playerState; + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/filters/query/TransientQuery.java b/PGM/src/main/java/tc/oc/pgm/filters/query/TransientQuery.java new file mode 100644 index 0000000..a05169b --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/filters/query/TransientQuery.java @@ -0,0 +1,17 @@ +package tc.oc.pgm.filters.query; + +import tc.oc.pgm.match.Match; + +public class TransientQuery implements ITransientQuery { + + private final IMatchQuery match; + + public TransientQuery(IMatchQuery match) { + this.match = match; + } + + @Override + public Match getMatch() { + return match.getMatch(); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/fireworks/FireworkUtil.java b/PGM/src/main/java/tc/oc/pgm/fireworks/FireworkUtil.java new file mode 100644 index 0000000..1b65467 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/fireworks/FireworkUtil.java @@ -0,0 +1,47 @@ +package tc.oc.pgm.fireworks; + +import javax.annotation.Nonnull; + +import org.bukkit.Bukkit; +import org.bukkit.FireworkEffect; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.block.Block; +import org.bukkit.entity.EntityType; +import org.bukkit.entity.Firework; +import org.bukkit.inventory.meta.FireworkMeta; + +import com.google.common.base.Preconditions; + +public class FireworkUtil { + public static @Nonnull Firework spawnFirework(@Nonnull Location location, @Nonnull FireworkEffect effect, int power) { + Preconditions.checkNotNull(location, "location"); + Preconditions.checkNotNull(effect, "firework effect"); + Preconditions.checkArgument(power >= 0, "power must be positive"); + + FireworkMeta meta = (FireworkMeta) Bukkit.getItemFactory().getItemMeta(Material.FIREWORK); + meta.setPower(power); + meta.addEffect(effect); + + Firework firework = (Firework) location.getWorld().spawnEntity(location, EntityType.FIREWORK); + firework.setFireworkMeta(meta); + + return firework; + } + + public static @Nonnull Location getOpenSpaceAbove(@Nonnull Location location) { + Preconditions.checkNotNull(location, "location"); + + Location result = location.clone(); + while(true) { + Block block = result.getBlock(); + if(block == null || block.getType() == Material.AIR) break; + + result.setY(result.getY() + 1); + } + + return result; + } + + private FireworkUtil() { } +} diff --git a/PGM/src/main/java/tc/oc/pgm/fireworks/FireworksConfig.java b/PGM/src/main/java/tc/oc/pgm/fireworks/FireworksConfig.java new file mode 100644 index 0000000..7b3ffec --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/fireworks/FireworksConfig.java @@ -0,0 +1,37 @@ +package tc.oc.pgm.fireworks; + +import tc.oc.pgm.Config; + +public class FireworksConfig { + public static class PostMatch { + public static boolean enabled() { + return Config.getConfiguration().getBoolean("fireworks.post-match.enabled", false); + } + + public static int number() { + return Math.max(1, Config.getConfiguration().getInt("fireworks.post-match.number", 5)); + } + + public static int delay() { + return Math.max(0, Config.getConfiguration().getInt("fireworks.post-match.delay", 40)); + } + + public static int frequency() { + return Math.max(1, Config.getConfiguration().getInt("fireworks.post-match.frequency", 40)); + } + + public static int iterations() { + return Math.max(1, Config.getConfiguration().getInt("fireworks.post-match.iterations", 15)); + } + + public static int power() { + return Math.max(0, Config.getConfiguration().getInt("fireworks.post-match.power", 2)); + } + } + + public static abstract class Goals { + public static boolean enabled() { + return Config.getConfiguration().getBoolean("fireworks.goals.enabled", false); + } + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/fireworks/ObjectivesFireworkListener.java b/PGM/src/main/java/tc/oc/pgm/fireworks/ObjectivesFireworkListener.java new file mode 100644 index 0000000..cceb899 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/fireworks/ObjectivesFireworkListener.java @@ -0,0 +1,99 @@ +package tc.oc.pgm.fireworks; + +import org.bukkit.Color; +import org.bukkit.FireworkEffect; +import org.bukkit.FireworkEffect.Type; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.geometry.Cuboid; +import tc.oc.commons.bukkit.util.BlockUtils; +import tc.oc.commons.bukkit.util.BukkitUtils; +import tc.oc.pgm.controlpoint.events.ControllerChangeEvent; +import tc.oc.pgm.core.CoreLeakEvent; +import tc.oc.pgm.destroyable.DestroyableDestroyedEvent; +import tc.oc.pgm.flag.event.FlagCaptureEvent; +import tc.oc.pgm.regions.Region; +import tc.oc.pgm.wool.PlayerWoolPlaceEvent; + +public class ObjectivesFireworkListener implements Listener { + + public void spawnFireworkDisplay(Location center, Color color, int count, double radius, int power) { + FireworkEffect effect = FireworkEffect.builder().with(Type.BURST) + .withFlicker() + .withColor(color) + .withFade(Color.BLACK) + .build(); + + for(int i = 0; i < count; i++) { + double angle = 2 * Math.PI / count * i; + double dx = radius * Math.cos(angle); + double dz = radius * Math.sin(angle); + Location baseLocation = center.clone().add(dx, 0, dz); + + Block block = baseLocation.getBlock(); + if(block == null || !block.getType().isOccluding()) { + FireworkUtil.spawnFirework(FireworkUtil.getOpenSpaceAbove(baseLocation), effect, power); + } + } + } + + public void spawnFireworkDisplay(World world, Region region, Color color, int count, double radiusMultiplier, int power) { + final Cuboid bound = region.getBounds(); + final double radius = bound.maximum().minus(bound.minimum()).times(0.5).length(); + final Location center = bound.minimum().getMidpoint(bound.maximum()).toLocation(world); + this.spawnFireworkDisplay(center, color, count, radiusMultiplier * radius, power); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onWoolPlace(final PlayerWoolPlaceEvent event) { + if(FireworksConfig.Goals.enabled() && event.getWool().isVisible()) { + this.spawnFireworkDisplay(BlockUtils.center(event.getBlock()), + event.getWool().getDyeColor().getColor(), + 6, 2, 2); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onCoreLeak(final CoreLeakEvent event) { + if(FireworksConfig.Goals.enabled() && event.getCore().isVisible()) { + this.spawnFireworkDisplay(event.getMatch().getWorld(), + event.getCore().getCasingRegion(), + event.getCore().getColor(), + 8, 1.5, 2); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onDestroyableBreak(final DestroyableDestroyedEvent event) { + if(FireworksConfig.Goals.enabled() && event.getDestroyable().isVisible()) { + this.spawnFireworkDisplay(event.getMatch().getWorld(), + event.getDestroyable().getBlockRegion(), + event.getDestroyable().getColor(), + 4, 3, 2); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onHillCapture(final ControllerChangeEvent event) { + if(FireworksConfig.Goals.enabled() && event.getControlPoint().isVisible() && event.getNewController() != null) { + this.spawnFireworkDisplay(event.getMatch().getWorld(), + event.getControlPoint().getCaptureRegion(), + BukkitUtils.colorOf(event.getNewController().getColor()), + 8, 1, 2); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onFlagCapture(final FlagCaptureEvent event) { + if(FireworksConfig.Goals.enabled() && event.getGoal().isVisible()) { + this.spawnFireworkDisplay(event.getMatch().getWorld(), + event.getNet().getRegion(), + event.getGoal().getDyeColor().getColor(), + 6, 1, 2); + } + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/fireworks/PostMatchFireworkListener.java b/PGM/src/main/java/tc/oc/pgm/fireworks/PostMatchFireworkListener.java new file mode 100644 index 0000000..08a7638 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/fireworks/PostMatchFireworkListener.java @@ -0,0 +1,97 @@ +package tc.oc.pgm.fireworks; + +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import javax.inject.Inject; + +import com.google.common.collect.ImmutableList; +import org.bukkit.Color; +import org.bukkit.FireworkEffect; +import org.bukkit.FireworkEffect.Type; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import tc.oc.commons.bukkit.util.BukkitUtils; +import tc.oc.commons.core.scheduler.Task; +import tc.oc.pgm.events.ListenerScope; +import tc.oc.pgm.events.MatchEndEvent; +import tc.oc.pgm.fireworks.FireworksConfig.PostMatch; +import tc.oc.pgm.match.Competitor; +import tc.oc.pgm.match.Match; +import tc.oc.pgm.match.MatchModule; +import tc.oc.pgm.match.MatchPlayer; +import tc.oc.pgm.match.MatchScope; +import tc.oc.pgm.victory.VictoryMatchModule; + +@ListenerScope(MatchScope.LOADED) +public class PostMatchFireworkListener extends MatchModule implements Listener { + + private Task task; + + @Inject PostMatchFireworkListener(Match match) { + super(match); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onMatchEnd(final MatchEndEvent event) { + if(!PostMatch.enabled()) return; + task = match.getScheduler(MatchScope.LOADED).createRepeatingTask( + PostMatch.delay(), + PostMatch.frequency(), + new FireworkRunner(event.getMatch().needMatchModule(VictoryMatchModule.class).winners()) + ); + } + + private void cancelTask() { + if(this.task != null) { + this.task.cancel(); + this.task = null; + } + } + + public static List AVAILABLE_TYPES = ImmutableList.builder() + .add(Type.BALL) + .add(Type.BALL_LARGE) + .add(Type.BURST) + .add(Type.STAR) + .build(); + + public class FireworkRunner implements Runnable { + private final Set colors; + private final Set winners; + private int iterations = 0; + + public FireworkRunner(Set winners) { + this.winners = winners; + this.colors = winners.stream() + .map(winner -> BukkitUtils.colorOf(winner.getColor())) + .collect(Collectors.toSet()); + } + + @Override + public void run() { + // Build this list fresh every time, because MatchPlayers can unload, but Competitors can't. + final List players = winners.stream() + .flatMap(c -> c.getPlayers().stream()) + .collect(Collectors.toList()); + Collections.shuffle(players); + + for(int i = 0; i < players.size() && i < PostMatch.number(); i++) { + MatchPlayer player = players.get(i); + + Type type = AVAILABLE_TYPES.get(match.getRandom().nextInt(AVAILABLE_TYPES.size())); + + FireworkEffect effect = FireworkEffect.builder().with(type).withFlicker().withColor(this.colors).withFade(Color.BLACK).build(); + + FireworkUtil.spawnFirework(player.getBukkit().getLocation(), effect, PostMatch.power()); + } + + this.iterations++; + if(this.iterations >= PostMatch.iterations()) { + cancelTask(); + } + } + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/flag/Flag.java b/PGM/src/main/java/tc/oc/pgm/flag/Flag.java new file mode 100644 index 0000000..76c5517 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/flag/Flag.java @@ -0,0 +1,547 @@ +package tc.oc.pgm.flag; + +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import javax.annotation.Nullable; + +import com.google.common.collect.ImmutableSet; +import net.md_5.bungee.api.ChatColor; +import net.md_5.bungee.api.chat.BaseComponent; +import net.md_5.bungee.api.chat.TranslatableComponent; +import org.bukkit.DyeColor; +import org.bukkit.FireworkEffect; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.Sound; +import org.bukkit.block.Banner; +import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; +import org.bukkit.block.BlockState; +import org.bukkit.entity.Firework; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.EntityDamageEvent; +import org.bukkit.event.entity.PlayerDeathEvent; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.player.PlayerDropItemEvent; +import org.bukkit.event.player.PlayerMoveEvent; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.BannerMeta; +import org.bukkit.util.BlockVector; +import tc.oc.commons.bukkit.chat.BukkitSound; +import tc.oc.commons.bukkit.chat.NameStyle; +import tc.oc.commons.bukkit.event.CoarsePlayerMoveEvent; +import tc.oc.commons.bukkit.util.BukkitUtils; +import tc.oc.commons.bukkit.util.Materials; +import tc.oc.commons.bukkit.util.NMSHacks; +import tc.oc.commons.bukkit.util.materials.Banners; +import tc.oc.commons.core.chat.Component; +import tc.oc.commons.core.util.Lazy; +import tc.oc.commons.core.util.Optionals; +import tc.oc.pgm.events.BlockTransformEvent; +import tc.oc.pgm.events.ListenerScope; +import tc.oc.pgm.events.PlayerLeavePartyEvent; +import tc.oc.pgm.filters.query.ILocationQuery; +import tc.oc.pgm.filters.query.IQuery; +import tc.oc.pgm.fireworks.FireworkUtil; +import tc.oc.pgm.flag.event.FlagCaptureEvent; +import tc.oc.pgm.flag.event.FlagStateChangeEvent; +import tc.oc.pgm.flag.state.BaseState; +import tc.oc.pgm.flag.state.Captured; +import tc.oc.pgm.flag.state.Completed; +import tc.oc.pgm.flag.state.Returned; +import tc.oc.pgm.flag.state.Spawned; +import tc.oc.pgm.flag.state.State; +import tc.oc.pgm.goals.TouchableGoal; +import tc.oc.pgm.goals.events.GoalCompleteEvent; +import tc.oc.pgm.goals.events.GoalEvent; +import tc.oc.pgm.goals.events.GoalStatusChangeEvent; +import tc.oc.pgm.match.Competitor; +import tc.oc.pgm.match.Match; +import tc.oc.pgm.match.MatchPlayer; +import tc.oc.pgm.match.MatchScope; +import tc.oc.pgm.match.ParticipantState; +import tc.oc.pgm.match.Party; +import tc.oc.pgm.module.ModuleLoadException; +import tc.oc.pgm.points.AngleProvider; +import tc.oc.pgm.points.PointProvider; +import tc.oc.pgm.points.StaticAngleProvider; +import tc.oc.pgm.regions.PointRegion; +import tc.oc.pgm.regions.Region; +import tc.oc.pgm.spawns.events.ParticipantDespawnEvent; +import tc.oc.pgm.teams.Team; +import tc.oc.pgm.teams.TeamMatchModule; + +@ListenerScope(MatchScope.LOADED) +public class Flag extends TouchableGoal implements Listener { + + public static final String RESPAWNING_SYMBOL = "\u2690"; // ⚐ + public static final String RETURNED_SYMBOL = "\u2691"; // ⚑ + public static final String DROPPED_SYMBOL = "\u2691"; // ⚑ + public static final String CARRIED_SYMBOL = "\u2794"; // ➔ + + public static final BukkitSound PICKUP_SOUND_OWN = new BukkitSound(Sound.ENTITY_WITHER_AMBIENT, 0.7f, 1.2f); + public static final BukkitSound DROP_SOUND_OWN = new BukkitSound(Sound.ENTITY_WITHER_HURT, 0.7f, 1); + public static final BukkitSound RETURN_SOUND_OWN = new BukkitSound(Sound.ENTITY_ZOMBIE_VILLAGER_CONVERTED, 1.1f, 1.2f); + + public static final BukkitSound PICKUP_SOUND = new BukkitSound(Sound.ENTITY_FIREWORK_LARGE_BLAST_FAR, 1f, 0.7f); + public static final BukkitSound DROP_SOUND = new BukkitSound(Sound.ENTITY_FIREWORK_TWINKLE_FAR, 1f, 1f); + public static final BukkitSound RETURN_SOUND = new BukkitSound(Sound.ENTITY_FIREWORK_TWINKLE_FAR, 1f, 1f); + + private final ImmutableSet nets; + private final @Nullable Team owner; + + private final Lazy> capturers; + private final Lazy> controllers; + private final Lazy> completers; + + private BaseState state; + private boolean transitioning; + + private final BannerInfo bannerInfo; + private static class BannerInfo { + final Location location; + final BannerMeta meta; + final ItemStack item; + final AngleProvider yawProvider; + + private BannerInfo(Location location, BannerMeta meta, ItemStack item, AngleProvider yawProvider) { + this.location = location; + this.meta = meta; + this.item = item; + this.yawProvider = yawProvider; + } + } + + protected Flag(Match match, FlagDefinition definition, ImmutableSet nets) throws ModuleLoadException { + super(definition, match); + this.nets = nets; + + final TeamMatchModule tmm = match.getMatchModule(TeamMatchModule.class); + + this.owner = definition.owner() + .map(def -> tmm.team(def)) // Do not use a method ref here, it will NPE if tmm is null + .orElse(null); + + this.capturers = Lazy.from( + () -> Optionals.stream(match.module(TeamMatchModule.class)) + .flatMap(TeamMatchModule::teams) + .filter(team -> getDefinition().canPickup(team) && canCapture(team)) + .collect(Collectors.toSet()) + ); + + this.controllers = Lazy.from( + () -> nets.stream() + .flatMap(net -> Optionals.stream(net.returnPost() + .flatMap(Post::owner))) + .map(def -> tmm.team(def)) + .collect(Collectors.toSet()) + ); + + this.completers = Lazy.from( + () -> nets.stream() + .flatMap(net -> Optionals.stream(net.returnPost())) + .filter(Post::isPermanent) + .flatMap(post -> Optionals.stream(post.owner())) + .map(def -> tmm.team(def)) + .collect(Collectors.toSet()) + ); + + Banner banner = null; + pointLoop: for(PointProvider returnPoint : definition.getDefaultPost().getReturnPoints()) { + Region region = returnPoint.getRegion(); + if(region instanceof PointRegion) { + // Do not require PointRegions to be at the exact center of the block. + // It might make sense to just override PointRegion.getBlockVectors() to + // always do this, but it does technically violate the contract of that method. + banner = toBanner(((PointRegion) region).getPosition().toLocation(match.getWorld()).getBlock()); + if(banner != null) break pointLoop; + } else { + for(BlockVector pos : returnPoint.getRegion().getBlockVectors()) { + banner = toBanner(pos.toLocation(match.getWorld()).getBlock()); + if(banner != null) break pointLoop; + } + } + } + + if(banner == null) { + throw new ModuleLoadException("Flag '" + getName() + "' must have a banner at its default post"); + } + + final Location location = Banners.getLocationWithYaw(banner); + bannerInfo = new BannerInfo(location, + Banners.getItemMeta(banner), + new ItemStack(Material.BANNER), + new StaticAngleProvider(location.getYaw())); + bannerInfo.item.setItemMeta(bannerInfo.meta); + + match.registerEvents(this); + + this.state = new Returned(this, this.getDefinition().getDefaultPost(), bannerInfo.location); + this.state.enterState(); + } + + private static Banner toBanner(Block block) { + if(block == null) return null; + BlockState state = block.getState(); + return state instanceof Banner ? (Banner) state : null; + } + + @Override + public String toString() { + return "Flag{name=" + this.getName() + " state=" + this.state + "}"; + } + + public DyeColor getDyeColor() { + DyeColor color = this.getDefinition().getColor(); + if(color == null) color = bannerInfo.meta.getBaseColor(); + return color; + } + + public net.md_5.bungee.api.ChatColor getChatColor() { + return BukkitUtils.toChatColor(this.getDyeColor()); + } + + public String getColoredName() { + return this.getChatColor() + this.getName(); + } + + public Component getComponentName() { + return new Component(getName()).color(getChatColor()); + } + + public ImmutableSet getNets() { + return nets; + } + + public BannerMeta getBannerMeta() { + return bannerInfo.meta; + } + + public ItemStack getBannerItem() { + return bannerInfo.item; + } + + public State state() { + return state; + } + + /** + * Owner is defined in XML, and does not change during a match + */ + public @Nullable Team getOwner() { + return owner; + } + + /** + * Physical location of the flag, if any + */ + public Optional getLocation() { + return state instanceof Spawned ? Optional.of(((Spawned) state).getLocation()) + : Optional.empty(); + } + + /** + * Controller is the owner of the {@link Post} the flag is at, which obviously can change + */ + public @Nullable Team getController() { + return this.state.getController(); + } + + public boolean hasMultipleControllers() { + return !controllers.get().isEmpty(); + } + + public boolean canDropOn(BlockState base) { + return Materials.isColliding(base.getType()) || (getDefinition().canDropOnWater() && Materials.isWater(base.getType())); + } + + public boolean canDropAt(Location location) { + if(!match.getWorld().equals(location.getWorld())) return false; + + Block block = location.getBlock(); + Block below = block.getRelative(BlockFace.DOWN); + if(!canDropOn(below.getState())) return false; + if(block.getRelative(BlockFace.UP).getType() != Material.AIR) return false; + + switch(block.getType()) { + case AIR: + case LONG_GRASS: + return true; + default: + return false; + } + } + + public boolean canDrop(ILocationQuery query) { + return canDropAt(query.getLocation()) && + getDefinition().getDropFilter().query(query).isAllowed(); + } + + public Location getReturnPoint(Post post) { + return post.getReturnPoint(this, bannerInfo.yawProvider).clone(); + } + + + // Touchable + + @Override + public boolean canTouch(ParticipantState player) { + MatchPlayer matchPlayer = player.getMatchPlayer(); + return matchPlayer != null && canPickup(matchPlayer, state.getPost()); + } + + @Override + public boolean showEnemyTouches() { + return true; + } + + @Override + public BaseComponent getTouchMessage(ParticipantState toucher, boolean self) { + if(self) { + return new TranslatableComponent("match.flag.pickup.you", getComponentName()); + } else { + return new TranslatableComponent("match.flag.pickup", getComponentName(), toucher.getStyledName(NameStyle.COLOR)); + } + } + + + // Proximity + + @Override + public Iterable getProximityLocations(ParticipantState player) { + return state.getProximityLocations(player); + } + + @Override + public boolean isProximityRelevant(Competitor team) { + if(hasTouched(team)) { + return canCapture(team); + } else { + return canPickup(team); + } + } + + + // Misc + + /** + * Transition to the given state. This happens immediately if not already transitioning. + * If this is called from within a transition, the state is queued and the transition + * happens after the current one completes. This allows {@link BaseState#enterState} to + * immediately transition into another state without nesting the transitions, and keeps + * the events in the correct order. + */ + public void transition(BaseState newState) { + if(this.transitioning) { + throw new IllegalStateException("Nested flag state transition"); + } + + BaseState oldState = this.state; + try { + logger.fine("Transitioning " + getName() + " from " + oldState + " to " + newState); + + this.transitioning = true; + this.state.leaveState(); + this.state = newState; + this.state.enterState(); + } finally { + this.transitioning = false; + } + + getMatch().callEvent(new FlagStateChangeEvent(this, oldState, this.state)); + + // If we are still in the state we just transitioned into, start the countdown, if any. + // We check this because the FlagStateChangeEvent may have already transitioned into another state. + if(this.state == newState) { + this.state.startCountdown(); + } + + // Check again, in case startCountdown transitioned. In that case, the nested + // transition will have already called these events if necessary. + if(this.state == newState) { + getMatch().callEvent(new GoalStatusChangeEvent(this)); + if(isCompleted()) { + getMatch().callEvent(new GoalCompleteEvent(this, + true, + c -> false, + c -> c.equals(getController()))); + } + } + } + + public boolean canPickup(IQuery query, Post post) { + return getDefinition().getPickupFilter().query(query).isAllowed() && + post.getPickupFilter().query(query).isAllowed(); + } + + public boolean canPickup(IQuery query) { + return canPickup(query, state.getPost()); + } + + public boolean canCapture(IQuery query, Net net) { + return getDefinition().getCaptureFilter().query(query).isAllowed() && + net.getCaptureFilter().query(query).isAllowed(); + } + + public boolean canCapture(IQuery query) { + return getDefinition().canCapture(query, getNets()); + } + + public boolean isCurrent(Class state) { + return state.isInstance(this.state); + } + + public boolean isCurrent(State state) { + return this.state == state; + } + + public boolean isCarrying(ParticipantState player) { + MatchPlayer matchPlayer = player.getMatchPlayer(); + return matchPlayer != null && isCarrying(matchPlayer); + } + + public boolean isCarrying(MatchPlayer player) { + return this.state.isCarrying(player); + } + + public boolean isCarrying(Competitor party) { + return this.state.isCarrying(party); + } + + public boolean isAtPost(Post post) { + return this.state.isAtPost(post); + } + + public boolean isCompletable() { + return !completers.get().isEmpty(); + } + + @Override + public boolean canComplete(Competitor team) { + return team instanceof Team && capturers.get().contains(team); + } + + @Override + public boolean isShared() { + // Flag is shared if it has multiple capturers or no capturers + return capturers.get().size() != 1; + } + + @Override + public boolean isCompleted() { + return isCurrent(Completed.class); + } + + @Override + public boolean isCompleted(Competitor team) { + return isCompleted() && getController() == team; + } + + public boolean isCaptured() { + return isCompleted() || isCurrent(Captured.class); + } + + @Override + public String renderSidebarStatusText(@Nullable Competitor competitor, Party viewer) { + return this.state.getStatusText(viewer); + } + + @Override + public ChatColor renderSidebarStatusColor(@Nullable Competitor competitor, Party viewer) { + return this.state.getStatusColor(viewer); + } + + @Override + public ChatColor renderSidebarLabelColor(@Nullable Competitor competitor, Party viewer) { + return this.state.getLabelColor(viewer); + } + + public void playFlareEffect() { + if(isCurrent(Spawned.class)) { + Location location = ((Spawned) this.state).getLocation(); + if(location == null) return; + FireworkEffect effect = FireworkEffect.builder().with(FireworkEffect.Type.BURST).withColor(this.getDyeColor().getColor()).build(); + Firework firework = FireworkUtil.spawnFirework(location, effect, 0); + NMSHacks.skipFireworksLaunch(firework); + } + } + + /** + * Play one of two status sounds depending on the team of the listener. + * Owning players hear the first sound, other players hear the second. + */ + public void playStatusSound(BukkitSound ownerSound, BukkitSound otherSound) { + for(MatchPlayer listener : getMatch().getPlayers()) { + if(listener.getParty() != null && (listener.getParty() == this.getOwner() || listener.getParty() == this.getController())) { + listener.playSound(ownerSound); + } else { + listener.playSound(otherSound); + } + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + private void onPlayerDeath(PlayerDeathEvent event) { + event.getDrops().removeIf(itemStack -> itemStack.isSimilar(this.getBannerItem())); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + private void onGoalChange(GoalEvent event) { + this.state.onEvent(event); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + private void onFlagStateChange(FlagStateChangeEvent event) { + this.state.onEvent(event); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + private void onFlagCapture(FlagCaptureEvent event) { + this.state.onEvent(event); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + private void onPlayerMove(PlayerMoveEvent event) { + if(event.getFrom().getWorld() == event.getTo().getWorld()) { // yes, this can be false + this.state.onEvent(event); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + private void onPlayerMove(CoarsePlayerMoveEvent event) { + this.state.onEvent(event); + } + + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + private void onBlockTransform(BlockTransformEvent event) { + this.state.onEvent(event); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + private void onItemDrop(PlayerDropItemEvent event) { + this.state.onEvent(event); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + private void onPlayerDespawn(ParticipantDespawnEvent event) { + this.state.onEvent(event); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + private void onPlayerDespawn(PlayerLeavePartyEvent event) { + this.state.onEvent(event); + } + + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + private void onInventoryClick(InventoryClickEvent event) { + this.state.onEvent(event); + } + + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + private void onProjectileHit(EntityDamageEvent event) { + this.state.onEvent(event); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/flag/FlagDefinition.java b/PGM/src/main/java/tc/oc/pgm/flag/FlagDefinition.java new file mode 100644 index 0000000..f593c8e --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/flag/FlagDefinition.java @@ -0,0 +1,239 @@ +package tc.oc.pgm.flag; + +import java.util.Collection; +import java.util.Optional; +import java.util.stream.Stream; +import javax.annotation.Nullable; + +import net.md_5.bungee.api.chat.BaseComponent; +import org.bukkit.DyeColor; +import tc.oc.api.docs.virtual.MapDoc; +import tc.oc.commons.bukkit.util.BukkitUtils; +import tc.oc.commons.core.stream.Collectors; +import tc.oc.pgm.features.FeatureInfo; +import tc.oc.pgm.features.GamemodeFeature; +import tc.oc.pgm.filters.Filter; +import tc.oc.pgm.filters.query.IQuery; +import tc.oc.pgm.goals.ProximityGoalDefinition; +import tc.oc.pgm.goals.ProximityGoalDefinitionImpl; +import tc.oc.pgm.goals.ProximityMetric; +import tc.oc.pgm.kits.Kit; +import tc.oc.pgm.match.Match; +import tc.oc.pgm.module.ModuleLoadException; +import tc.oc.pgm.teams.TeamFactory; + +@FeatureInfo(name = "flag") +public interface FlagDefinition extends ProximityGoalDefinition, GamemodeFeature { + + @Nullable DyeColor getColor(); + + @Override + String getColoredName(); + + Post getDefaultPost(); + + double getPointsPerCapture(); + + double getPointsPerSecond(); + + Filter getPickupFilter(); + + Filter getDropFilter(); + + Filter getCaptureFilter(); + + @Nullable Kit getPickupKit(); + + @Nullable Kit getDropKit(); + + @Nullable Kit getCarryKit(); + + boolean hasMultipleCarriers(); + + @Nullable BaseComponent getCarryMessage(); + + boolean canDropOnWater(); + + boolean showBeam(); + + boolean canPickup(IQuery query); + + boolean canCapture(IQuery query, Collection nets); +} + +class FlagDefinitionImpl extends ProximityGoalDefinitionImpl implements FlagDefinition { + + private static String makeName(@Nullable String name, @Nullable DyeColor color) { + if(name != null) return name; + if(color != null) return color.name().charAt(0) + color.name().substring(1).toLowerCase() + " Flag"; + return "Flag"; + } + + private final @Inspect @Nullable DyeColor color; // Flag color, null detects color from the banner at match load time + private final @Inspect Post defaultPost; // Flag starts the match at this post + private final @Inspect double pointsPerCapture; // Points awarded for capturing this flag, in addition to points from the Net + private final @Inspect double pointsPerSecond; // Points awarded while carrying this flag + private final @Inspect Filter pickupFilter; // Filter players who can pickup this flag + private final @Inspect Filter dropFilter; // Filter players who can drop the flag + private final @Inspect Filter captureFilter; // Filter players who can capture this flag + private final @Inspect @Nullable Kit pickupKit; // Kit to give on flag pickup + private final @Inspect @Nullable Kit dropKit; // Kit to give carrier when they drop the flag + private final @Inspect @Nullable Kit carryKit; // Kit to give to/take from the flag carrier + private final @Inspect boolean multiCarrier; // Affects how the flag appears in the scoreboard + private final @Inspect @Nullable BaseComponent carryMessage; // Custom message to show flag carrier + private final @Inspect boolean dropOnWater; // Flag can freeze water to drop on it + private final @Inspect boolean showBeam; + + public FlagDefinitionImpl(@Nullable String name, + @Nullable Boolean required, + boolean visible, + @Nullable DyeColor color, + Post defaultPost, + Optional owner, + double pointsPerCapture, + double pointsPerSecond, + Filter pickupFilter, + Filter dropFilter, + Filter captureFilter, + @Nullable Kit pickupKit, + @Nullable Kit dropKit, + @Nullable Kit carryKit, + boolean multiCarrier, + @Nullable BaseComponent carryMessage, + boolean dropOnWater, + boolean showBeam, + ProximityMetric flagProximityMetric, + ProximityMetric netProximityMetric) { + + // We can't use the owner field in OwnedGoal because our owner + // is a reference that can't be resolved until after parsing. + super(makeName(name, color), required, visible, owner, flagProximityMetric, netProximityMetric); + + this.color = color; + this.defaultPost = defaultPost; + this.pointsPerCapture = pointsPerCapture; + this.pointsPerSecond = pointsPerSecond; + this.pickupFilter = pickupFilter; + this.dropFilter = dropFilter; + this.captureFilter = captureFilter; + this.pickupKit = pickupKit; + this.dropKit = dropKit; + this.carryKit = carryKit; + this.multiCarrier = multiCarrier; + this.carryMessage = carryMessage; + this.dropOnWater = dropOnWater; + this.showBeam = showBeam; + } + + @Override + public Stream gamemodes() { + return Stream.of(MapDoc.Gamemode.ctf); + } + + @Override + public boolean isShared() { + return true; + } + + @Override + public @Nullable DyeColor getColor() { + return this.color; + } + + @Override + public String getColoredName() { + if(this.getColor() != null) { + return BukkitUtils.dyeColorToChatColor(this.getColor()) + this.getName(); + } else { + return super.getColoredName(); + } + } + + @Override + public Post getDefaultPost() { + return this.defaultPost; + } + + @Override + public double getPointsPerCapture() { + return this.pointsPerCapture; + } + + @Override + public double getPointsPerSecond() { + return this.pointsPerSecond; + } + + @Override + public Filter getPickupFilter() { + return this.pickupFilter; + } + + @Override + public Filter getDropFilter() { + return dropFilter; + } + + @Override + public Filter getCaptureFilter() { + return captureFilter; + } + + @Override + public @Nullable Kit getPickupKit() { + return pickupKit; + } + + @Override + public @Nullable Kit getDropKit() { + return dropKit; + } + + @Override + public @Nullable Kit getCarryKit() { + return carryKit; + } + + @Override + public boolean hasMultipleCarriers() { + return multiCarrier; + } + + @Override + public @Nullable BaseComponent getCarryMessage() { + return carryMessage; + } + + @Override + public boolean canDropOnWater() { + return dropOnWater; + } + + @Override + public boolean showBeam() { + return showBeam; + } + + @Override + public Flag createFeature(Match match) throws ModuleLoadException { + return new Flag(match, this, match.featureDefinitions() + .all(Net.class) + .filter(net -> net.getCapturableFlags().contains(this)) + .collect(Collectors.toImmutableSet())); + } + + @Override + public boolean canPickup(IQuery query) { + return getPickupFilter().query(query).isAllowed() && + getDefaultPost().getPickupFilter().query(query).isAllowed(); + } + + @Override + public boolean canCapture(IQuery query, Collection nets) { + if(getCaptureFilter().query(query).isDenied()) return false; + for(Net net : nets) { + if(net.getCaptureFilter().query(query).isAllowed()) return true; + } + return false; + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/flag/FlagManifest.java b/PGM/src/main/java/tc/oc/pgm/flag/FlagManifest.java new file mode 100644 index 0000000..698bf28 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/flag/FlagManifest.java @@ -0,0 +1,11 @@ +package tc.oc.pgm.flag; + +import tc.oc.commons.core.inject.HybridManifest; +import tc.oc.pgm.map.inject.MapBinders; + +public class FlagManifest extends HybridManifest implements MapBinders { + @Override + protected void configure() { + rootParsers().addBinding().to(FlagParser.class); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/flag/FlagParser.java b/PGM/src/main/java/tc/oc/pgm/flag/FlagParser.java new file mode 100644 index 0000000..263b9dd --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/flag/FlagParser.java @@ -0,0 +1,219 @@ +package tc.oc.pgm.flag; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.logging.Logger; +import java.util.stream.Stream; +import javax.annotation.Nullable; +import javax.inject.Inject; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import net.md_5.bungee.api.chat.BaseComponent; +import org.bukkit.DyeColor; +import org.bukkit.util.Vector; +import org.jdom2.Document; +import org.jdom2.Element; +import java.time.Duration; +import tc.oc.commons.core.stream.Collectors; +import tc.oc.pgm.features.FeatureParser; +import tc.oc.pgm.filters.Filter; +import tc.oc.pgm.filters.matcher.StaticFilter; +import tc.oc.pgm.filters.parser.FilterParser; +import tc.oc.pgm.goals.ProximityMetric; +import tc.oc.pgm.kits.Kit; +import tc.oc.pgm.kits.KitParser; +import tc.oc.pgm.kits.RemovableValidation; +import tc.oc.pgm.map.MapModuleContext; +import tc.oc.pgm.map.MapRootParser; +import tc.oc.pgm.points.PointParser; +import tc.oc.pgm.points.PointProvider; +import tc.oc.pgm.points.PointProviderAttributes; +import tc.oc.pgm.regions.Region; +import tc.oc.pgm.regions.RegionParser; +import tc.oc.pgm.teams.TeamFactory; +import tc.oc.pgm.utils.XMLUtils; +import tc.oc.pgm.xml.InvalidXMLException; +import tc.oc.pgm.xml.Node; + +import static tc.oc.commons.core.exception.LambdaExceptionUtils.rethrowFunction; + +public class FlagParser implements MapRootParser { + + private final Document document; + private final MapModuleContext context; + private final PointParser pointParser; + private final Logger logger; + private final FilterParser filterParser; + private final RegionParser regionParser; + private final KitParser kitParser; + private final FeatureParser teamParser; + private final List flags = new ArrayList<>(); + + @Inject private FlagParser(Document document, MapModuleContext context, PointParser pointParser, Logger logger, FeatureParser teamParser) { + this.document = document; + this.context = context; + this.pointParser = pointParser; + this.logger = logger; + this.filterParser = context.needModule(FilterParser.class); + this.regionParser = context.needModule(RegionParser.class); + this.kitParser = context.needModule(KitParser.class); + this.teamParser = teamParser; + } + + private void checkDeprecatedFilter(Element el) throws InvalidXMLException { + Node node = Node.fromChildOrAttr(el, "filter"); + if(node != null) { + throw new InvalidXMLException("'filter' is no longer supported, be more specific e.g. 'pickup-filter'", node); + } + } + + public Post parsePost(Element el) throws InvalidXMLException { + checkDeprecatedFilter(el); + + final Optional owner = teamParser.property(el, "owner").optional(); + boolean sequential = XMLUtils.parseBoolean(el.getAttribute("sequential"), false); + boolean permanent = XMLUtils.parseBoolean(el.getAttribute("permanent"), false); + double pointsPerSecond = XMLUtils.parseNumber(el.getAttribute("points-rate"), Double.class, 0D); + Filter pickupFilter = filterParser.property(el, "pickup-filter").optional(StaticFilter.ALLOW); + + Duration recoverTime = XMLUtils.parseDuration(Node.fromAttr(el, "recover-time", "return-time"), Post.DEFAULT_RETURN_TIME); + Duration respawnTime = XMLUtils.parseDuration(el.getAttribute("respawn-time"), null); + Double respawnSpeed = XMLUtils.parseNumber(el.getAttribute("respawn-speed"), Double.class, (Double) null); + ImmutableList returnPoints = ImmutableList.copyOf(pointParser.parse(el, new PointProviderAttributes())); + + if(respawnTime == null && respawnSpeed == null) { + respawnSpeed = Post.DEFAULT_RESPAWN_SPEED; + } + + if(respawnTime != null && respawnSpeed != null) { + throw new InvalidXMLException("post cannot have both respawn-time and respawn-speed", el); + } + + if(returnPoints.isEmpty()) { + throw new InvalidXMLException("post must have at least one point provider", el); + } + + return context.features().define(el, Post.class, new PostImpl(owner, recoverTime, respawnTime, respawnSpeed, returnPoints, sequential, permanent, pointsPerSecond, pickupFilter)); + } + + public ImmutableSet parseFlagSet(Node node) throws InvalidXMLException { + return Stream.of(node.getValue().split("\\s")) + .map(rethrowFunction(flagId -> context.features().reference(node, flagId, FlagDefinition.class))) + .collect(Collectors.toImmutableSet()); + } + + public Net parseNet(Element el, @Nullable FlagDefinition parentFlag) throws InvalidXMLException { + checkDeprecatedFilter(el); + Region region = regionParser.property(el).union(); + final Optional owner = teamParser.property(el, "owner").optional(); + double pointsPerCapture = XMLUtils.parseNumber(el.getAttribute("points"), Double.class, 0D); + boolean sticky = XMLUtils.parseBoolean(el.getAttribute("sticky"), true); + Filter captureFilter = filterParser.property(el, "capture-filter").optional(StaticFilter.ALLOW); + Filter respawnFilter = filterParser.property(el, "respawn-filter").optional(StaticFilter.ALLOW); + boolean respawnTogether = XMLUtils.parseBoolean(el.getAttribute("respawn-together"), false); + BaseComponent respawnMessage = XMLUtils.parseFormattedText(el, "respawn-message"); + BaseComponent denyMessage = XMLUtils.parseFormattedText(el, "deny-message"); + Vector proximityLocation = XMLUtils.parseVector(el.getAttribute("location"), (Vector) null); + + Post returnPost = null; + Node postAttr = Node.fromAttr(el, "post"); + if(postAttr != null) { + // Posts are all parsed at this point, so we can do an immediate lookup + returnPost = context.features().reference(postAttr, Post.class); + if(returnPost == null) { + throw new InvalidXMLException("No post with ID '" + postAttr.getValue() + "'", postAttr); + } + } + + ImmutableSet capturableFlags; + Node flagsAttr = Node.fromAttr(el, "flag", "flags"); + if(flagsAttr != null) { + if(parentFlag != null) { + throw new InvalidXMLException("Cannot specify flags on a net that is defined inside a flag", flagsAttr); + } + capturableFlags = this.parseFlagSet(flagsAttr); + } else if(parentFlag != null) { + capturableFlags = ImmutableSet.of(parentFlag); + } else { + capturableFlags = ImmutableSet.copyOf(this.flags); + } + + ImmutableSet returnableFlags; + Node returnableNode = Node.fromAttr(el, "rescue", "return"); + if(returnableNode != null) { + returnableFlags = this.parseFlagSet(returnableNode); + } else { + returnableFlags = ImmutableSet.of(); + } + + return context.features().define(el, Net.class, new NetImpl(region, captureFilter, respawnFilter, owner, pointsPerCapture, sticky, denyMessage, respawnMessage, returnPost, capturableFlags, returnableFlags, respawnTogether, proximityLocation)); + } + + public FlagDefinition parseFlag(Element el) throws InvalidXMLException { + checkDeprecatedFilter(el); + + String name = el.getAttributeValue("name"); + boolean visible = XMLUtils.parseBoolean(el.getAttribute("show"), true); + Boolean required = XMLUtils.parseBoolean(el.getAttribute("required"), null); + DyeColor color = XMLUtils.parseDyeColor(el.getAttribute("color"), null); + final Optional owner = teamParser.property(el, "owner").optional(); + double pointsPerCapture = XMLUtils.parseNumber(el.getAttribute("points"), Double.class, 0D); + double pointsPerSecond = XMLUtils.parseNumber(el.getAttribute("points-rate"), Double.class, 0D); + Filter pickupFilter = filterParser.property(el, "pickup-filter").optional(null); + if(pickupFilter == null) pickupFilter = filterParser.property(el, "filter").optional(StaticFilter.ALLOW); + Filter dropFilter = filterParser.property(el, "drop-filter").optional(StaticFilter.ALLOW); + Filter captureFilter = filterParser.property(el, "capture-filter").optional(StaticFilter.ALLOW); + Kit pickupKit = kitParser.property(el, "pickup-kit").optional(null); + Kit dropKit = kitParser.property(el, "drop-kit").optional(null); + Kit carryKit = kitParser.property(el, "carry-kit") + .validate(RemovableValidation.get()) + .optional(null); + boolean multiCarrier = XMLUtils.parseBoolean(el.getAttribute("shared"), false); + BaseComponent carryMessage = XMLUtils.parseFormattedText(el, "carry-message"); + boolean dropOnWater = XMLUtils.parseBoolean(el.getAttribute("drop-on-water"), true); + boolean showBeam = XMLUtils.parseBoolean(el.getAttribute("beam"), true); + ProximityMetric flagProximityMetric = ProximityMetric.parse(el, "flag", new ProximityMetric(ProximityMetric.Type.CLOSEST_KILL, false)); + ProximityMetric netProximityMetric = ProximityMetric.parse(el, "net", new ProximityMetric(ProximityMetric.Type.CLOSEST_PLAYER, false)); + + Post defaultPost; + Element elPost = XMLUtils.getUniqueChild(el, "post"); + if(elPost != null) { + // Parse nested + defaultPost = this.parsePost(elPost); + } else { + Node postAttr = Node.fromRequiredAttr(el, "post"); + defaultPost = context.features().reference(postAttr, Post.class); + if(defaultPost == null) { + throw new InvalidXMLException("No post with ID '" + postAttr.getValue() + "'", postAttr); + } + } + + FlagDefinition flag = context.features().define(el, FlagDefinition.class, new FlagDefinitionImpl(name, required, visible, color, defaultPost, owner, pointsPerCapture, pointsPerSecond, pickupFilter, dropFilter, captureFilter, pickupKit, dropKit, carryKit, multiCarrier, carryMessage, dropOnWater, showBeam, flagProximityMetric, netProximityMetric)); + flags.add(flag); + + // Parse nested s + for(Element elNet : el.getChildren("net")) { + this.parseNet(elNet, flag); + } + + return flag; + } + + @Override + public void parse() throws InvalidXMLException { + // Order of these is important to avoid the need for forward refs + for(Element el : XMLUtils.flattenElements(document.getRootElement(), "flags", "post")) { + this.parsePost(el); + } + + for(Element el : XMLUtils.flattenElements(document.getRootElement(), "flags", "flag")) { + this.parseFlag(el); + } + + for(Element el : XMLUtils.flattenElements(document.getRootElement(), "flags", "net")) { + this.parseNet(el, null); + } + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/flag/Net.java b/PGM/src/main/java/tc/oc/pgm/flag/Net.java new file mode 100644 index 0000000..b715eea --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/flag/Net.java @@ -0,0 +1,167 @@ +package tc.oc.pgm.flag; + +import java.util.Optional; +import javax.annotation.Nullable; + +import com.google.common.collect.ImmutableSet; +import net.md_5.bungee.api.chat.BaseComponent; +import org.bukkit.util.Vector; +import tc.oc.pgm.features.FeatureDefinition; +import tc.oc.pgm.features.FeatureInfo; +import tc.oc.pgm.features.SluggedFeatureDefinition; +import tc.oc.pgm.filters.Filter; +import tc.oc.pgm.regions.Region; +import tc.oc.pgm.teams.TeamFactory; + +@FeatureInfo(name = "net") +public interface Net extends SluggedFeatureDefinition { + + Region getRegion(); + + Filter getCaptureFilter(); + + Filter getRespawnFilter(); + + @Nullable BaseComponent getRespawnMessage(); + + Optional owner(); + + @Nullable TeamFactory getOwner(); + + double getPointsPerCapture(); + + boolean isSticky(); + + @Nullable BaseComponent getDenyMessage(); + + @Nullable Post getReturnPost(); + + default Optional returnPost() { return Optional.ofNullable(getReturnPost()); } + + ImmutableSet getCapturableFlags(); + + ImmutableSet getRecoverableFlags(); + + boolean isRespawnTogether(); + + Vector getProximityLocation(); +} + +class NetImpl extends FeatureDefinition.Impl implements Net { + + private final @Inspect Region region; // Region flag carrier must enter to capture + private final @Inspect Filter captureFilter; // Carrier must pass this filter to capture + private final @Inspect Filter respawnFilter; // Captured flags will not respawn until they pass this filter + private final @Inspect Optional owner; // Team that gets points for captures in this net, null to give points to flag carrier + private final @Inspect double pointsPerCapture; // Points awarded per capture + private final @Inspect boolean sticky; // If capture is delayed by filter, carrier does not have to stay inside the net + private final @Inspect @Nullable BaseComponent denyMessage; // Message to show carrier when capture is prevented by filter + private final @Inspect @Nullable BaseComponent respawnMessage; // Message to broadcast when respawn is prevented by filter or respawnTogether + private final @Inspect @Nullable Post returnPost; // Post to send flags after capture, null to send to their current post + private final @Inspect ImmutableSet capturableFlags; // Flags that can be captured in this net + private final @Inspect ImmutableSet recoverableFlags; // Flags that are force returned on capture, aside from the flag being captured + private final @Inspect boolean respawnTogether; // Delay respawn until all capturableFlags are captured + + private @Nullable Vector proximityLocation; + + public NetImpl(Region region, + Filter captureFilter, + Filter respawnFilter, + Optional owner, + double pointsPerCapture, + boolean sticky, + @Nullable BaseComponent denyMessage, + @Nullable BaseComponent respawnMessage, + @Nullable Post returnPost, + ImmutableSet capturableFlags, + ImmutableSet recoverableFlags, + boolean respawnTogether, + @Nullable Vector proximityLocation) { + + this.region = region; + this.captureFilter = captureFilter; + this.respawnFilter = respawnFilter; + this.owner = owner; + this.pointsPerCapture = pointsPerCapture; + this.sticky = sticky; + this.denyMessage = denyMessage; + this.respawnMessage = respawnMessage; + this.returnPost = returnPost; + this.capturableFlags = capturableFlags; + this.recoverableFlags = recoverableFlags; + this.respawnTogether = respawnTogether; + this.proximityLocation = proximityLocation; + } + + @Override + public Region getRegion() { + return this.region; + } + + @Override + public Filter getCaptureFilter() { + return captureFilter; + } + + @Override + public Filter getRespawnFilter() { + return respawnFilter; + } + + @Override + public @Nullable BaseComponent getRespawnMessage() { + return respawnMessage; + } + + public Optional owner() { + return owner; + } + + @Override + public @Nullable TeamFactory getOwner() { + return owner.orElse(null); + } + + @Override + public double getPointsPerCapture() { + return pointsPerCapture; + } + + @Override + public boolean isSticky() { + return sticky; + } + + @Override + public @Nullable BaseComponent getDenyMessage() { + return denyMessage; + } + + @Override + public @Nullable Post getReturnPost() { + return this.returnPost; + } + + @Override + public ImmutableSet getCapturableFlags() { + return capturableFlags; + } + + @Override + public ImmutableSet getRecoverableFlags() { + return recoverableFlags; + } + + @Override + public boolean isRespawnTogether() { + return respawnTogether; + } + + @Override + public Vector getProximityLocation() { + if(proximityLocation == null) { + proximityLocation = getRegion().getBounds().center(); + } + return proximityLocation; + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/flag/Post.java b/PGM/src/main/java/tc/oc/pgm/flag/Post.java new file mode 100644 index 0000000..3295979 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/flag/Post.java @@ -0,0 +1,193 @@ +package tc.oc.pgm.flag; + +import java.util.Optional; +import java.util.Random; +import javax.annotation.Nullable; + +import com.google.common.collect.ImmutableList; +import net.md_5.bungee.api.ChatColor; +import org.bukkit.Location; +import java.time.Duration; +import tc.oc.pgm.features.FeatureDefinition; +import tc.oc.pgm.features.FeatureInfo; +import tc.oc.pgm.filters.Filter; +import tc.oc.pgm.points.AngleProvider; +import tc.oc.pgm.points.PointProvider; +import tc.oc.pgm.points.PointProviderLocation; +import tc.oc.pgm.teams.TeamFactory; + +import static com.google.common.base.Preconditions.checkArgument; + +@FeatureInfo(name = "post") +public interface Post extends FeatureDefinition { + + Duration DEFAULT_RETURN_TIME = Duration.ofSeconds(30); + double DEFAULT_RESPAWN_SPEED = 8; + + Optional owner(); + + @Nullable TeamFactory getOwner(); + + ChatColor getColor(); + + Duration getRecoverTime(); + + Duration getRespawnTime(double distance); + + ImmutableList getReturnPoints(); + + boolean isSequential(); + + Boolean isPermanent(); + + double getPointsPerSecond(); + + Filter getPickupFilter(); + + Location getReturnPoint(Flag flag, AngleProvider yawProvider); +} + +class PostImpl extends FeatureDefinition.Impl implements Post { + + private static final int MAX_SPAWN_ATTEMPTS = 100; + + private final @Inspect Optional owner; // Team that owns the post, affects various things + private final @Inspect Duration recoverTime; // Time between a flag dropping and being recovered, can be infinite + private final @Inspect @Nullable Duration respawnTime; // Fixed time between a flag being recovered and respawning at the post + private final @Inspect @Nullable Double respawnSpeed; // Makes respawn time proportional to distance, flag "moves" back at this m/s + private final @Inspect ImmutableList returnPoints; // Spawn points for the flag + private final @Inspect boolean sequential; // Search for spawn points sequentially, see equivalent field in SpawnInfo + private final @Inspect boolean permanent; // Flag enters Completed state when at this post + private final @Inspect double pointsPerSecond; // Points awarded while any flag is at this post + private final @Inspect Filter pickupFilter; // Filter players who can pickup a flag at this post + + public PostImpl(Optional owner, + Duration recoverTime, + @Nullable Duration respawnTime, + @Nullable Double respawnSpeed, + ImmutableList returnPoints, + boolean sequential, + boolean permanent, + double pointsPerSecond, + Filter pickupFilter) { + + checkArgument(respawnTime == null || respawnSpeed == null); + if(respawnSpeed != null) checkArgument(respawnSpeed > 0); + + this.owner = owner; + this.recoverTime = recoverTime; + this.respawnTime = respawnTime; + this.respawnSpeed = respawnSpeed; + this.returnPoints = returnPoints; + this.sequential = sequential; + this.permanent = permanent; + this.pointsPerSecond = pointsPerSecond; + this.pickupFilter = pickupFilter; + } + + @Override + public Optional owner() { + return owner; + } + + @Override + public @Nullable TeamFactory getOwner() { + return owner.orElse(null); + } + + @Override + public ChatColor getColor() { + return owner.map(TeamFactory::getDefaultColor) + .orElse(ChatColor.WHITE); + } + + @Override + public Duration getRecoverTime() { + return this.recoverTime; + } + + @Override + public Duration getRespawnTime(double distance) { + if(respawnTime != null) { + return respawnTime; + } else if(respawnSpeed != null) { + return Duration.ofSeconds(Math.round(distance / respawnSpeed)); + } else { + return Duration.ZERO; + } + } + + @Override + public ImmutableList getReturnPoints() { + return this.returnPoints; + } + + @Override + public boolean isSequential() { + return this.sequential; + } + + @Override + public Boolean isPermanent() { + return this.permanent; + } + + @Override + public double getPointsPerSecond() { + return this.pointsPerSecond; + } + + @Override + public Filter getPickupFilter() { + return this.pickupFilter; + } + + @Override + public Location getReturnPoint(Flag flag, AngleProvider yawProvider) { + Location location = getReturnPoint(flag); + if(location instanceof PointProviderLocation && !((PointProviderLocation) location).hasYaw()) { + location.setYaw(yawProvider.getAngle(location.toVector())); + } + return location; + } + + private Location getReturnPoint(Flag flag) { + if(this.sequential) { + for(PointProvider provider : this.returnPoints) { + for(int i = 0; i < MAX_SPAWN_ATTEMPTS; i++) { + Location loc = roundToBlock(provider.getPoint(flag.getMatch(), null)); + if(flag.canDropAt(loc)) { + return loc; + } + } + } + + // could not find a good spot, fallback to the last provider + return this.returnPoints.get(this.returnPoints.size() - 1).getPoint(flag.getMatch(), null); + + } else { + Random random = new Random(); + for(int i = 0; i < MAX_SPAWN_ATTEMPTS * this.returnPoints.size(); i++) { + PointProvider provider = this.returnPoints.get(random.nextInt(this.returnPoints.size())); + Location loc = roundToBlock(provider.getPoint(flag.getMatch(), null)); + if(flag.canDropAt(loc)) { + return loc; + } + } + + // could not find a good spot, settle for any spot + PointProvider provider = this.returnPoints.get(random.nextInt(this.returnPoints.size())); + return this.returnPoints.get(random.nextInt(this.returnPoints.size())).getPoint(flag.getMatch(), null); + } + } + + private Location roundToBlock(Location loc) { + Location newLoc = loc.clone(); + + newLoc.setX(Math.floor(loc.getX()) + 0.5); + newLoc.setY(Math.floor(loc.getY())); + newLoc.setZ(Math.floor(loc.getZ()) + 0.5); + + return newLoc; + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/flag/event/FlagCaptureEvent.java b/PGM/src/main/java/tc/oc/pgm/flag/event/FlagCaptureEvent.java new file mode 100644 index 0000000..6d4a855 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/flag/event/FlagCaptureEvent.java @@ -0,0 +1,51 @@ +package tc.oc.pgm.flag.event; + +import tc.oc.pgm.flag.Flag; +import tc.oc.pgm.flag.FlagDefinition; +import tc.oc.pgm.flag.Net; +import tc.oc.pgm.goals.events.GoalCompleteEvent; +import tc.oc.pgm.match.MatchPlayer; + +import static com.google.common.base.Preconditions.checkNotNull; + +public class FlagCaptureEvent extends GoalCompleteEvent { + + private final Net net; + private final MatchPlayer carrier; + private final boolean allFlagsCaptured; + + public FlagCaptureEvent(Flag flag, MatchPlayer carrier, Net net) { + super(flag, true, c -> false, c -> c.equals(carrier.getParty())); + + this.net = checkNotNull(net); + this.carrier = checkNotNull(carrier); + + boolean allFlagsCaptured = true; + for(FlagDefinition def : this.net.getCapturableFlags()) { + if(!def.getGoal(getMatch()).isCaptured()) allFlagsCaptured = false; + } + this.allFlagsCaptured = allFlagsCaptured; + } + + @Override + public Flag getGoal() { + return (Flag) super.getGoal(); + } + + public Net getNet() { + return net; + } + + public MatchPlayer getCarrier() { + return carrier; + } + + /** + * True if all the flags that can be captured in this net are currently in the + * {@link tc.oc.pgm.flag.state.Captured} state, as of the moment the event was fired. + * (they may not necessarily be in that state when the listener receives the event). + */ + public boolean areAllFlagsCaptured() { + return allFlagsCaptured; + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/flag/event/FlagPickupEvent.java b/PGM/src/main/java/tc/oc/pgm/flag/event/FlagPickupEvent.java new file mode 100644 index 0000000..fcdb4b0 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/flag/event/FlagPickupEvent.java @@ -0,0 +1,62 @@ +package tc.oc.pgm.flag.event; + +import org.bukkit.Location; +import org.bukkit.event.Cancellable; +import org.bukkit.event.Event; +import org.bukkit.event.HandlerList; +import tc.oc.pgm.match.MatchPlayer; +import tc.oc.pgm.flag.Flag; +import tc.oc.pgm.eventrules.EventRuleMatchModule; + +/** + * Fired BEFORE a player picks up a flag, allowing it to be cancelled. + * This is used by {@link EventRuleMatchModule} to prevent + * flags from being picked up inside regions that the player would not be + * allowed to enter if they were already carrying it. + */ +public class FlagPickupEvent extends Event implements Cancellable { + + private final Flag flag; + private final MatchPlayer carrier; + private final Location location; + private boolean cancelled; + + public FlagPickupEvent(Flag flag, MatchPlayer carrier, Location location) { + this.flag = flag; + this.carrier = carrier; + this.location = location; + } + + public Flag getFlag() { + return flag; + } + + public MatchPlayer getCarrier() { + return carrier; + } + + public Location getLocation() { + return location; + } + + @Override + public boolean isCancelled() { + return this.cancelled; + } + + @Override + public void setCancelled(boolean yes) { + this.cancelled = yes; + } + + private static final HandlerList handlers = new HandlerList(); + + public static HandlerList getHandlerList() { + return handlers; + } + + @Override + public HandlerList getHandlers() { + return handlers; + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/flag/event/FlagStateChangeEvent.java b/PGM/src/main/java/tc/oc/pgm/flag/event/FlagStateChangeEvent.java new file mode 100644 index 0000000..b0dedf9 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/flag/event/FlagStateChangeEvent.java @@ -0,0 +1,47 @@ +package tc.oc.pgm.flag.event; + +import org.bukkit.event.HandlerList; +import tc.oc.pgm.flag.Flag; +import tc.oc.pgm.flag.state.State; +import tc.oc.pgm.goals.events.GoalEvent; + +/** + * Fired AFTER any transition of the {@link State} of a {@link Flag} + */ +public class FlagStateChangeEvent extends GoalEvent { + + protected final Flag flag; + protected final State oldState, newState; + + public FlagStateChangeEvent(Flag flag, State oldState, State newState) { + super(flag); + this.flag = flag; + this.oldState = oldState; + this.newState = newState; + } + + public Flag getFlag() { + return flag; + } + + public State getOldState() { + return oldState; + } + + public State getNewState() { + return newState; + } + + // HandlerList crap + + private static final HandlerList handlers = new HandlerList(); + + public static HandlerList getHandlerList() { + return handlers; + } + + @Override + public HandlerList getHandlers() { + return handlers; + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/flag/state/BaseState.java b/PGM/src/main/java/tc/oc/pgm/flag/state/BaseState.java new file mode 100644 index 0000000..36087ba --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/flag/state/BaseState.java @@ -0,0 +1,205 @@ +package tc.oc.pgm.flag.state; + +import java.util.Collections; +import java.util.Objects; + +import net.md_5.bungee.api.ChatColor; +import org.bukkit.Location; +import org.bukkit.event.entity.EntityDamageEvent; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.player.PlayerDropItemEvent; +import org.bukkit.event.player.PlayerMoveEvent; +import java.time.Duration; +import java.time.Instant; +import tc.oc.commons.bukkit.event.CoarsePlayerMoveEvent; +import tc.oc.commons.core.scheduler.Task; +import tc.oc.commons.core.util.TimeUtils; +import tc.oc.pgm.events.PlayerLeavePartyEvent; +import tc.oc.pgm.match.MatchPlayer; +import tc.oc.pgm.match.MatchScope; +import tc.oc.pgm.match.ParticipantState; +import tc.oc.pgm.match.Party; +import tc.oc.pgm.spawns.events.ParticipantDespawnEvent; +import tc.oc.pgm.teams.Team; +import tc.oc.pgm.events.BlockTransformEvent; +import tc.oc.pgm.goals.events.GoalEvent; +import tc.oc.pgm.flag.Flag; +import tc.oc.pgm.flag.Post; +import tc.oc.pgm.flag.event.FlagCaptureEvent; +import tc.oc.pgm.flag.event.FlagStateChangeEvent; +import tc.oc.pgm.teams.TeamMatchModule; + +import javax.annotation.Nullable; + +/** + * Base class for all {@link Flag} states + */ +public abstract class BaseState implements Runnable, State { + + protected final Flag flag; + protected final Post post; + protected final Instant enterTime; + protected @Nullable Long remainingTicks; + private Task task; + + protected BaseState(Flag flag, Post post) { + this.flag = flag; + this.post = post; + this.enterTime = Instant.now(); + } + + @Override + public String toString() { + return this.getClass().getSimpleName(); + } + + /** + * Called just after the flag's state has been changed to this object. + * + * This method is NOT allowed to transition this flag into any other state, and doing so + * will throw an exception. Care must be taken that any events fired cannot cause another + * transition for this flag. Transitioning other flags is OK. + * + * If this state wants to immediately transition, it should return zero from {@link #getDuration}, + * which will cause {@link #finishCountdown} to be called after this method returns. + */ + public void enterState() { + this.task = this.flag.getMatch().getScheduler(MatchScope.LOADED).createRepeatingTask(1, 1, this); + } + + public void leaveState() { + if(this.task != null) { + this.task.cancel(); + this.task = null; + } + } + + @Override + public Iterable getProximityLocations(ParticipantState player) { + return Collections.emptySet(); + } + + @Override + public boolean isCurrent() { + return this.flag.isCurrent(this); + } + + protected @Nullable Duration getDuration() { + return null; + } + + public void startCountdown() { + Duration duration = getDuration(); + if(duration != null) { + if(Duration.ZERO.equals(duration)) { + this.finishCountdown(); + } else if(!TimeUtils.isInfPositive(duration)) { + this.remainingTicks = duration.toMillis() / 50; + } + } + } + + protected boolean isCountingDown() { + return this.flag.getMatch().isRunning() && this.remainingTicks != null && this.remainingTicks > 0; + } + + protected long getRemainingSeconds() { + return this.remainingTicks == null ? -1 : (this.remainingTicks + 19) / 20; + } + + @Override + public void run() { + this.tickLoaded(); + if(this.flag.getMatch().isRunning()) this.tickRunning(); + } + + public void tickLoaded() { } + + public void tickRunning() { + if(this.isCountingDown()) { + long before = this.getRemainingSeconds(); + if(--this.remainingTicks == 0) { + this.finishCountdown(); + } + long after = this.getRemainingSeconds(); + + if(before != after) { + this.tickSeconds(after); + } + } + } + + protected void tickSeconds(long seconds) { } + + protected void finishCountdown() { } + + @Override + public boolean isCarrying(MatchPlayer player) { + return false; + } + + @Override + public boolean isCarrying(ParticipantState player) { + MatchPlayer matchPlayer = player.getMatchPlayer(); + return matchPlayer != null && isCarrying(matchPlayer); + } + + @Override + public boolean isCarrying(Party party) { + return false; + } + + @Override + public Post getPost() { + return this.post; + } + + @Override + public boolean isAtPost(Post post) { + return Objects.equals(post, this.post); + } + + @Override + public @Nullable Team getController() { + if(this.post.getOwner() != null) { + return this.flag.getMatch().needMatchModule(TeamMatchModule.class).team(this.post.getOwner()); + } else { + return null; + } + } + + public ChatColor getStatusColor(Party viewer) { + return this.flag.getChatColor(); + } + + public ChatColor getLabelColor(Party viewer) { + if(this.flag.hasMultipleControllers()) { + Team controller = this.getController(); + return controller != null ? controller.getColor() : ChatColor.WHITE; + } else { + return ChatColor.WHITE; + } + } + + public String getStatusText(Party viewer) { + if(this.isCountingDown()) { + return String.valueOf(this.getRemainingSeconds()); + } else { + return this.getStatusSymbol(viewer); + } + } + + public abstract String getStatusSymbol(Party viewer); + + public void onEvent(GoalEvent event) { } + public void onEvent(FlagStateChangeEvent event) { } + public void onEvent(FlagCaptureEvent event) { } + public void onEvent(PlayerMoveEvent event) { } + public void onEvent(CoarsePlayerMoveEvent event) { } + public void onEvent(BlockTransformEvent event) { } + public void onEvent(PlayerDropItemEvent event) { } + public void onEvent(ParticipantDespawnEvent event) { } + public void onEvent(PlayerLeavePartyEvent event) { } + public void onEvent(InventoryClickEvent event) { } + public void onEvent(EntityDamageEvent event) { } +} diff --git a/PGM/src/main/java/tc/oc/pgm/flag/state/Captured.java b/PGM/src/main/java/tc/oc/pgm/flag/state/Captured.java new file mode 100644 index 0000000..cf248f2 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/flag/state/Captured.java @@ -0,0 +1,88 @@ +package tc.oc.pgm.flag.state; + +import net.md_5.bungee.api.ChatColor; +import net.md_5.bungee.api.chat.TranslatableComponent; +import org.bukkit.Location; +import tc.oc.pgm.match.Party; +import tc.oc.pgm.flag.Flag; +import tc.oc.pgm.flag.Net; +import tc.oc.pgm.flag.Post; +import tc.oc.pgm.flag.event.FlagCaptureEvent; +import tc.oc.pgm.flag.event.FlagStateChangeEvent; + +/** + * Flag is looking for a place to respawn after being captured. + * This phase can be delayed by a respawn-filter. + */ +public class Captured extends BaseState implements Returning { + + protected final Net net; + protected final Location lastLocation; + protected boolean wasDelayed; + + protected Captured(Flag flag, Post post, Net net, Location lastLocation) { + super(flag, post); + this.net = net; + this.lastLocation = lastLocation; + } + + protected boolean tryRespawn(boolean allFlagsCaptured) { + if((!this.net.isRespawnTogether() || allFlagsCaptured) && + this.net.getRespawnFilter().query(this.flag.getMatch()).isAllowed()) { + + this.flag.transition(new Respawning(this.flag, this.post, this.lastLocation, true, this.wasDelayed)); + return true; + } else { + return false; + } + } + + @Override + public void tickRunning() { + super.tickRunning(); + + // This will only be called if respawn was initially prevented by the filter, + // which is the only case in which we want to broadcast the message. + if(!this.wasDelayed) { + if(this.net.getRespawnMessage() != null) { + this.flag.getMatch().sendMessage(this.net.getRespawnMessage()); + } else if(this.net.isRespawnTogether()) { + this.flag.getMatch().sendMessage(new TranslatableComponent("match.flag.respawnTogether", this.flag.getComponentName())); + } + } + + this.wasDelayed = true; + tryRespawn(false); + } + + @Override + public void onEvent(FlagCaptureEvent event) { + super.onEvent(event); + tryRespawn(event.areAllFlagsCaptured() && + event.getNet().getCapturableFlags().contains(this.flag.getDefinition())); + } + + @Override + public void onEvent(FlagStateChangeEvent event) { + super.onEvent(event); + // Try to respawn immediately after any flag changes state, + // in case it changed the filter result. This state will + // receive the event for its own state change, and immediately + // transition out if respawn is not prevented by the filter. + tryRespawn(false); + } + + @Override + public String getStatusSymbol(Party viewer) { + return Flag.RESPAWNING_SYMBOL; + } + + @Override + public ChatColor getStatusColor(Party viewer) { + if(this.flag.getDefinition().hasMultipleCarriers()) { + return ChatColor.WHITE; + } else { + return super.getStatusColor(viewer); + } + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/flag/state/Carried.java b/PGM/src/main/java/tc/oc/pgm/flag/state/Carried.java new file mode 100644 index 0000000..34dd99b --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/flag/state/Carried.java @@ -0,0 +1,382 @@ +package tc.oc.pgm.flag.state; + +import java.util.ArrayDeque; +import java.util.Deque; +import javax.annotation.Nullable; + +import net.md_5.bungee.api.ChatColor; +import net.md_5.bungee.api.ChatMessageType; +import net.md_5.bungee.api.chat.BaseComponent; +import net.md_5.bungee.api.chat.TranslatableComponent; +import org.bukkit.Location; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.player.PlayerDropItemEvent; +import org.bukkit.inventory.ItemStack; +import tc.oc.commons.bukkit.event.CoarsePlayerMoveEvent; +import tc.oc.commons.bukkit.inventory.ArmorType; +import tc.oc.commons.core.IterableUtils; +import tc.oc.commons.core.chat.Component; +import tc.oc.commons.core.chat.Components; +import tc.oc.pgm.events.PlayerLeavePartyEvent; +import tc.oc.pgm.filters.query.PlayerQueryWithLocation; +import tc.oc.pgm.flag.Flag; +import tc.oc.pgm.flag.FlagDefinition; +import tc.oc.pgm.flag.Net; +import tc.oc.pgm.flag.Post; +import tc.oc.pgm.flag.event.FlagCaptureEvent; +import tc.oc.pgm.flag.event.FlagStateChangeEvent; +import tc.oc.pgm.goals.events.GoalEvent; +import tc.oc.pgm.kits.Kit; +import tc.oc.pgm.kits.KitPlayerFacet; +import tc.oc.pgm.match.Competitor; +import tc.oc.pgm.match.MatchPlayer; +import tc.oc.pgm.match.MatchScope; +import tc.oc.pgm.match.ParticipantState; +import tc.oc.pgm.match.Party; +import tc.oc.pgm.score.ScoreMatchModule; +import tc.oc.pgm.scoreboard.SidebarMatchModule; +import tc.oc.pgm.spawns.events.ParticipantDespawnEvent; +import tc.oc.pgm.teams.TeamFactory; +import tc.oc.pgm.teams.TeamMatchModule; + +/** + * State of a flag when a player has picked it up and is wearing the banner on their head. + */ +public class Carried extends Spawned implements Missing { + + protected final MatchPlayer carrier; + protected ItemStack helmetItem; + protected @Nullable Net deniedByNet; + protected @Nullable Flag deniedByFlag; + protected @Nullable BaseComponent lastMessage; + + private static final int DROP_QUEUE_SIZE = 100; + private Deque dropLocations = new ArrayDeque<>(DROP_QUEUE_SIZE); + + public Carried(Flag flag, Post post, MatchPlayer carrier, Location dropLocation) { + super(flag, post); + this.carrier = carrier; + this.dropLocations.add(dropLocation); // Need an initial dropLocation in case the carrier never generates ones + } + + @Override + public boolean isRecoverable() { + return true; + } + + @Override + public Location getLocation() { + return this.carrier.getBukkit().getLocation(); + } + + @Override + public Iterable getProximityLocations(ParticipantState player) { + if(isCarrying(player)) { + return IterableUtils.transfilter(flag.getNets(), (net) -> { + if(net.getCaptureFilter().query(player).isAllowed()) { + return net.getProximityLocation().toLocation(flag.getMatch().getWorld()); + } + else { + return null; + } + }); + } else { + return super.getProximityLocations(player); + } + } + + @Override + public void enterState() { + super.enterState(); + + Kit kit = this.flag.getDefinition().getPickupKit(); + if(kit != null) carrier.facet(KitPlayerFacet.class).applyKit(kit, false); + kit = this.flag.getDefinition().getCarryKit(); + if(kit != null) carrier.facet(KitPlayerFacet.class).applyKit(kit, false); + + this.helmetItem = this.carrier.getBukkit().getInventory().getHelmet(); + + this.carrier.getBukkit().getInventory().setHelmet(this.flag.getBannerItem().clone()); + + SidebarMatchModule smm = this.flag.getMatch().getMatchModule(SidebarMatchModule.class); + if(smm != null) smm.blinkGoal(this.flag, 2, null); + } + + @Override + public void leaveState() { + SidebarMatchModule smm = this.flag.getMatch().getMatchModule(SidebarMatchModule.class); + if(smm != null) smm.stopBlinkingGoal(this.flag); + + this.carrier.getBukkit().sendMessage(ChatMessageType.ACTION_BAR, Components.blank()); + + this.carrier.getInventory().remove(this.flag.getBannerItem()); + this.carrier.getInventory().setHelmet(this.helmetItem); + + Kit kit = this.flag.getDefinition().getDropKit(); + if(kit != null) this.carrier.facet(KitPlayerFacet.class).applyKit(kit, false); + kit = this.flag.getDefinition().getCarryKit(); + if(kit != null) kit.remove(this.carrier); + + super.leaveState(); + } + + protected Competitor getBeneficiary(TeamFactory owner) { + if(owner != null) { + return this.flag.getMatch().needMatchModule(TeamMatchModule.class).team(owner); + } else { + return this.carrier.getCompetitor(); + } + } + + protected BaseComponent getMessage() { + BaseComponent message; + if(this.deniedByNet == null) { + if(this.flag.getDefinition().getCarryMessage() != null) { + message = this.flag.getDefinition().getCarryMessage(); + } else { + message = new TranslatableComponent("match.flag.carrying.you", this.flag.getComponentName()); + } + + message.setColor(ChatColor.AQUA); + message.setBold(true); + return message; + } else { + if(this.deniedByNet.getDenyMessage() != null) { + message = this.deniedByNet.getDenyMessage(); + } else if(this.deniedByFlag != null){ + message = new TranslatableComponent("match.flag.captureDenied.byFlag", + this.flag.getComponentName(), + this.deniedByFlag.getComponentName()); + } else { + message = new TranslatableComponent("match.flag.captureDenied", + this.flag.getComponentName()); + } + + message.setColor(ChatColor.RED); + message.setBold(true); + return message; + } + } + + @Override + public void tickRunning() { + super.tickRunning(); + + BaseComponent message = this.getMessage(); + this.carrier.sendHotbarMessage(message); + + if(!Components.equals(message, this.lastMessage)) { + this.lastMessage = message; + this.carrier.showTitle(new Component(), message, 0, 5, 35); + } + + ScoreMatchModule smm = this.flag.getMatch().getMatchModule(ScoreMatchModule.class); + if(smm != null && this.flag.getDefinition().getPointsPerSecond() > 0) { + smm.incrementScore(this.getBeneficiary(this.flag.getDefinition().getOwner()), + this.flag.getDefinition().getPointsPerSecond() / 20D); + } + } + + @Override + public boolean isCarrying(MatchPlayer player) { + return this.carrier == player; + } + + @Override + public boolean isCarrying(Party party) { + return this.carrier.getParty() == party; + } + + @Override + protected boolean canSeeParticles(Player player) { + return player != this.carrier.getBukkit(); + } + + protected void dropFlag() { + for(Location dropLocation : this.dropLocations) { + if(this.flag.canDrop(new PlayerQueryWithLocation(carrier, dropLocation))) { + this.flag.transition(new Dropped(this.flag, this.post, dropLocation, this.carrier)); + return; + } + } + + // Could not find a usable drop location, just recover the flag + forceRecover(); + } + + protected void captureFlag(Net net) { + this.carrier.sendMessage(new TranslatableComponent("match.flag.capture.you", + this.flag.getComponentName())); + + this.flag.getMatch().sendMessageExcept(new TranslatableComponent("match.flag.capture", + this.flag.getComponentName(), + this.carrier.getComponentName()), + this.carrier); + + this.flag.resetTouches(this.carrier.getCompetitor()); + this.flag.resetProximity(this.carrier.getCompetitor()); + + ScoreMatchModule smm = this.flag.getMatch().getMatchModule(ScoreMatchModule.class); + if(smm != null) { + if(net.getPointsPerCapture() != 0) { + smm.incrementScore(this.getBeneficiary(net.getOwner()), + net.getPointsPerCapture()); + } + + if(this.flag.getDefinition().getPointsPerCapture() != 0) { + smm.incrementScore(this.getBeneficiary(this.flag.getDefinition().getOwner()), + this.flag.getDefinition().getPointsPerCapture()); + } + } + + Post post = net.getReturnPost() != null ? net.getReturnPost() : this.post; + if(post.isPermanent()) { + this.flag.transition(new Completed(this.flag, post)); + } else { + this.flag.transition(new Captured(this.flag, post, net, this.getLocation())); + } + + FlagCaptureEvent event = new FlagCaptureEvent(this.flag, this.carrier, net); + this.flag.getMatch().callEvent(event); + } + + protected boolean isCarrier(MatchPlayer player) { + return carrier.equals(player); + } + + protected boolean isCarrier(Entity player) { + return carrier.getBukkit().equals(player); + } + + protected boolean isFlag(ItemStack stack) { + return stack.isSimilar(this.flag.getBannerItem()); + } + + @Override + public void onEvent(PlayerDropItemEvent event) { + super.onEvent(event); + if(this.isCarrier(event.getPlayer()) && this.isFlag(event.getItemDrop().getItemStack())) { + event.getItemDrop().remove(); + dropFlag(); + } + } + + @Override + public void onEvent(ParticipantDespawnEvent event) { + super.onEvent(event); + // Don't drop the flag when the match ends and everyone despawns + if(isCarrier(event.getPlayer()) && flag.getMatch().isRunning()) { + dropFlag(); + } + } + + @Override + public void onEvent(PlayerLeavePartyEvent event) { + super.onEvent(event); + // Handle flag carrier disconnecting after match ends. + // Disconnect during the match is handled by the despawn event instead. + if(isCarrier(event.getPlayer()) && flag.getMatch().isFinished()) { + dropFlag(); + } + } + + @Override + public void onEvent(InventoryClickEvent event) { + super.onEvent(event); + if(this.isCarrier(event.getWhoClicked()) && event.getSlot() == ArmorType.HELMET.inventorySlot()) { + event.setCancelled(true); + event.getView().setCursor(null); + event.setCurrentItem(null); + this.flag.getMatch().getScheduler(MatchScope.RUNNING).createTask(() -> { + if(isCurrent()) { + dropFlag(); + } + }); + } + } + + @Override + public void onEvent(CoarsePlayerMoveEvent event) { + super.onEvent(event); + + if(this.isCarrier(event.getPlayer())) { + final Location playerLoc = event.getBlockTo(); + if((dropLocations.isEmpty() || !dropLocations.peekLast().equals(playerLoc)) && + flag.canDrop(new PlayerQueryWithLocation(carrier, playerLoc))) { + + if(this.dropLocations.size() >= DROP_QUEUE_SIZE) this.dropLocations.removeLast(); + this.dropLocations.addFirst(playerLoc); + } + + this.checkCapture(playerLoc); + } + } + + @Override + public void onEvent(GoalEvent event) { + super.onEvent(event); + this.checkCapture(null); + } + + @Override + public void onEvent(FlagStateChangeEvent event) { + super.onEvent(event); + this.checkCapture(null); + } + + protected void checkCapture(Location to) { + if(to == null) to = this.carrier.getBukkit().getLocation(); + + this.deniedByFlag = null; + if(this.deniedByNet != null && !this.deniedByNet.isSticky()) { + this.deniedByNet = null; + } + + for(Net net : this.flag.getNets()) { + if(net.getRegion().contains(to)) { + if(tryCapture(net)) { + return; + } else { + this.deniedByNet = net; + } + } + } + + if(this.deniedByNet != null) { + tryCapture(this.deniedByNet); + } + } + + protected boolean tryCapture(Net net) { + for(FlagDefinition returnableDef : net.getRecoverableFlags()) { + Flag returnable = returnableDef.getGoal(this.flag.getMatch()); + if(returnable.isCurrent(Carried.class)) { + this.deniedByFlag = returnable; + return false; + } + } + + if(this.flag.canCapture(this.carrier, net)) { + this.captureFlag(net); + return true; + } else { + return false; + } + } + + @Override + public ChatColor getStatusColor(Party viewer) { + if(this.flag.getDefinition().hasMultipleCarriers()) { + return this.carrier.getParty().getColor(); + } else { + return super.getStatusColor(viewer); + } + } + + @Override + public String getStatusSymbol(Party viewer) { + return Flag.CARRIED_SYMBOL; + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/flag/state/Completed.java b/PGM/src/main/java/tc/oc/pgm/flag/state/Completed.java new file mode 100644 index 0000000..1d3a7b9 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/flag/state/Completed.java @@ -0,0 +1,40 @@ +package tc.oc.pgm.flag.state; + +import java.util.Collections; + +import net.md_5.bungee.api.ChatColor; +import org.bukkit.Location; +import tc.oc.pgm.match.MatchPlayer; +import tc.oc.pgm.match.ParticipantState; +import tc.oc.pgm.match.Party; +import tc.oc.pgm.flag.Flag; +import tc.oc.pgm.flag.Post; +import tc.oc.pgm.goals.SimpleGoal; + +// Flag has been permanently captured +public class Completed extends Returned { + + public Completed(Flag flag, Post post) { + super(flag, post, null); + } + + @Override + public Iterable getProximityLocations(ParticipantState player) { + return Collections.emptySet(); + } + + @Override + protected boolean canPickup(MatchPlayer player) { + return false; + } + + @Override + public ChatColor getStatusColor(Party viewer) { + return SimpleGoal.COLOR_COMPLETE; + } + + @Override + public String getStatusSymbol(Party viewer) { + return SimpleGoal.SYMBOL_COMPLETE; + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/flag/state/Dropped.java b/PGM/src/main/java/tc/oc/pgm/flag/state/Dropped.java new file mode 100644 index 0000000..d611806 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/flag/state/Dropped.java @@ -0,0 +1,99 @@ +package tc.oc.pgm.flag.state; + +import java.time.Duration; +import java.time.Instant; + +import net.md_5.bungee.api.ChatColor; +import net.md_5.bungee.api.chat.TranslatableComponent; +import org.bukkit.Location; +import tc.oc.commons.core.util.TimeUtils; +import tc.oc.pgm.flag.Flag; +import tc.oc.pgm.flag.Post; +import tc.oc.pgm.goals.events.GoalStatusChangeEvent; +import tc.oc.pgm.match.MatchPlayer; +import tc.oc.pgm.match.Party; +import tc.oc.pgm.scoreboard.SidebarMatchModule; + +/** + * State of a flag after a player drops it on the ground, either by dying + * or by clicking on the banner in their inventory. A flag can only enter + * this state when subject to a return delay. + */ +public class Dropped extends Uncarried implements Missing { + + // Minimum time between a player dropping the flag and picking it up again + private static final Duration PICKUP_DELAY = Duration.ofSeconds(2); + + private final MatchPlayer dropper; + + public Dropped(Flag flag, Post post, Location location, MatchPlayer dropper) { + super(flag, post, location); + this.dropper = dropper; + } + + @Override + protected Duration getDuration() { + return this.post.getRecoverTime(); + } + + @Override + public void enterState() { + super.enterState(); + + if(!Duration.ZERO.equals(getDuration())) { + this.flag.playStatusSound(Flag.DROP_SOUND_OWN, Flag.DROP_SOUND); + this.flag.getMatch().sendMessage(new TranslatableComponent("match.flag.drop", this.flag.getComponentName())); + } + + if(TimeUtils.isInfPositive(getDuration())) { + SidebarMatchModule smm = this.flag.getMatch().getMatchModule(SidebarMatchModule.class); + if(smm != null) smm.blinkGoal(this.flag, 2, null); + } + } + + @Override + public void leaveState() { + SidebarMatchModule smm = this.flag.getMatch().getMatchModule(SidebarMatchModule.class); + if(smm != null) smm.stopBlinkingGoal(this.flag); + super.leaveState(); + } + + @Override + protected void tickSeconds(long seconds) { + super.tickSeconds(seconds); + this.flag.getMatch().callEvent(new GoalStatusChangeEvent(this.flag)); + this.labelEntity.setCustomName(this.flag.getColoredName() + " " + ChatColor.AQUA + seconds); + } + + @Override + protected void finishCountdown() { + super.finishCountdown(); + this.recover(); + } + + @Override + public boolean isRecoverable() { + return true; + } + + @Override + protected boolean canPickup(MatchPlayer player) { + return super.canPickup(player) && (player != this.dropper || this.enterTime.plus(PICKUP_DELAY).isBefore(Instant.now())); + } + + @Override + public ChatColor getStatusColor(Party viewer) { + if(this.isCountingDown()) { + return ChatColor.AQUA; + } else if(this.flag.getDefinition().hasMultipleCarriers()) { + return ChatColor.WHITE; + } else { + return super.getStatusColor(viewer); + } + } + + @Override + public String getStatusSymbol(Party viewer) { + return Flag.DROPPED_SYMBOL; + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/flag/state/Missing.java b/PGM/src/main/java/tc/oc/pgm/flag/state/Missing.java new file mode 100644 index 0000000..bd2e13f --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/flag/state/Missing.java @@ -0,0 +1,10 @@ +package tc.oc.pgm.flag.state; + +/** + * Flag is not in a safe place, or about to be. + * + * This interface is currently only used to group states. + */ +public interface Missing extends State { + +} diff --git a/PGM/src/main/java/tc/oc/pgm/flag/state/Respawning.java b/PGM/src/main/java/tc/oc/pgm/flag/state/Respawning.java new file mode 100644 index 0000000..dba2056 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/flag/state/Respawning.java @@ -0,0 +1,104 @@ +package tc.oc.pgm.flag.state; + +import net.md_5.bungee.api.ChatColor; +import net.md_5.bungee.api.chat.BaseComponent; +import net.md_5.bungee.api.chat.TranslatableComponent; +import org.bukkit.Location; +import java.time.Duration; +import tc.oc.commons.core.chat.Component; +import tc.oc.pgm.match.Party; +import tc.oc.pgm.goals.events.GoalStatusChangeEvent; +import tc.oc.pgm.flag.Flag; +import tc.oc.pgm.flag.Post; + +import javax.annotation.Nullable; + +/** + * State of a flag while it is waiting to respawn at a {@link Post} + * after being {@link Captured}. This phase can be delayed by + * respawn-time or respawn-speed. + */ +public class Respawning extends Spawned implements Returning { + + protected final @Nullable Location respawnFrom; + protected final Location respawnTo; + protected final Duration respawnTime; + protected final boolean wasCaptured; + protected final boolean wasDelayed; + + protected Respawning(Flag flag, Post post, @Nullable Location respawnFrom, boolean wasCaptured, boolean wasDelayed) { + super(flag, post); + this.respawnFrom = respawnFrom; + this.respawnTo = this.flag.getReturnPoint(this.post); + this.respawnTime = this.post.getRespawnTime(this.respawnFrom == null ? 0 : this.respawnFrom.distance(this.respawnTo)); + this.wasCaptured = wasCaptured; + this.wasDelayed = wasDelayed; + } + + @Override + protected Duration getDuration() { + return respawnTime; + } + + @Override + public void enterState() { + super.enterState(); + + if(!Duration.ZERO.equals(respawnTime)) { + // Respawn is delayed + this.flag.getMatch().sendMessage(new TranslatableComponent("match.flag.willRespawn", + this.flag.getColoredName(), + new Component(String.valueOf(respawnTime.getSeconds()), net.md_5.bungee.api.ChatColor.AQUA))); + } + } + + protected void respawn(@Nullable BaseComponent message) { + if(message != null) { + this.flag.playStatusSound(Flag.RETURN_SOUND_OWN, Flag.RETURN_SOUND); + this.flag.getMatch().sendMessage(message); + } + + this.flag.transition(new Returned(this.flag, this.post, this.respawnTo)); + } + + @Override + protected void tickSeconds(long seconds) { + super.tickSeconds(seconds); + this.flag.getMatch().callEvent(new GoalStatusChangeEvent(this.flag)); + } + + @Override + protected void finishCountdown() { + super.finishCountdown(); + + if(!Duration.ZERO.equals(respawnTime)) { + this.respawn(new TranslatableComponent("match.flag.respawn", this.flag.getColoredName())); + } else if(!this.wasCaptured) { + // Flag was dropped + this.respawn(new TranslatableComponent("match.flag.return", this.flag.getColoredName())); + } else if(this.wasDelayed) { + // Flag was captured and respawn was delayed by a filter, so we announce that the flag has respawned + this.respawn(new TranslatableComponent("match.flag.respawn", this.flag.getColoredName())); + } + } + + @Override + public Location getLocation() { + return this.respawnTo; + } + + @Override + public boolean isRecoverable() { + return false; + } + + @Override + public String getStatusSymbol(Party viewer) { + return Flag.RESPAWNING_SYMBOL; + } + + @Override + public ChatColor getStatusColor(Party viewer) { + return ChatColor.GRAY; + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/flag/state/Returned.java b/PGM/src/main/java/tc/oc/pgm/flag/state/Returned.java new file mode 100644 index 0000000..23d470c --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/flag/state/Returned.java @@ -0,0 +1,63 @@ +package tc.oc.pgm.flag.state; + +import java.util.Collections; + +import net.md_5.bungee.api.ChatColor; +import org.bukkit.Location; +import tc.oc.pgm.match.ParticipantState; +import tc.oc.pgm.match.Party; +import tc.oc.pgm.flag.Flag; +import tc.oc.pgm.flag.Post; +import tc.oc.pgm.score.ScoreMatchModule; +import tc.oc.pgm.teams.TeamMatchModule; + +import javax.annotation.Nullable; + +/** + * State of a flag after being returned to a {@link Post}, or at the start of + * the match when at its initial post. + */ +public class Returned extends Uncarried implements Runnable { + + public Returned(Flag flag, Post home, @Nullable Location location) { + super(flag, home, location); + } + + @Override + public boolean isRecoverable() { + return false; + } + + @Override + public Iterable getProximityLocations(ParticipantState player) { + if(!flag.hasTouched(player.getParty())) { + return Collections.singleton(getLocation()); + } else { + return super.getProximityLocations(player); + } + } + + @Override + public void tickRunning() { + super.tickRunning(); + + ScoreMatchModule smm = this.flag.getMatch().getMatchModule(ScoreMatchModule.class); + if(smm != null && this.post.getOwner() != null && this.post.getPointsPerSecond() > 0) { + smm.incrementScore(this.flag.getMatch().needMatchModule(TeamMatchModule.class).team(this.post.getOwner()), this.post.getPointsPerSecond() / 20D); + } + } + + @Override + public ChatColor getStatusColor(Party viewer) { + if(this.flag.getDefinition().hasMultipleCarriers()) { + return ChatColor.WHITE; + } else { + return super.getStatusColor(viewer); + } + } + + @Override + public String getStatusSymbol(Party viewer) { + return Flag.RETURNED_SYMBOL; + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/flag/state/Returning.java b/PGM/src/main/java/tc/oc/pgm/flag/state/Returning.java new file mode 100644 index 0000000..e9078f5 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/flag/state/Returning.java @@ -0,0 +1,10 @@ +package tc.oc.pgm.flag.state; + +/** + * Flag is not returned, but it is safe. + * + * This interface is currently only used to group states. + */ +public interface Returning { + +} diff --git a/PGM/src/main/java/tc/oc/pgm/flag/state/Spawned.java b/PGM/src/main/java/tc/oc/pgm/flag/state/Spawned.java new file mode 100644 index 0000000..790e835 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/flag/state/Spawned.java @@ -0,0 +1,92 @@ +package tc.oc.pgm.flag.state; + +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.util.Vector; +import tc.oc.commons.bukkit.util.NMSHacks; +import tc.oc.pgm.flag.Flag; +import tc.oc.pgm.flag.Post; +import tc.oc.pgm.flag.event.FlagCaptureEvent; + +/** + * Base class for flag states in which the banner is physically present + * somewhere in the map (i.e. most of them). + */ +public abstract class Spawned extends BaseState { + + protected int particleClock; + + public Spawned(Flag flag, Post post) { + super(flag, post); + } + + // Location the flag must travel from to respawn + public abstract Location getLocation(); + + /** + * True if the flag can transition from its current state to a {@link Respawning} state. + * This is false when the flag is already in that state, or in a {@link Returned} state, + * in which case there is no reason to respawn it. + */ + public abstract boolean isRecoverable(); + + /** + * Transition to a {@link Respawning} state, if the flag {@link #isRecoverable()} + * in its current state. + * + * Does nothing unless the match is running. + */ + protected void recover() { + if(flag.getMatch().isRunning()) { + forceRecover(); + } + } + + /** + * Transition to a {@link Respawning} state, if the flag {@link #isRecoverable()} + * in its current state. + * + * This method works even if the match is over, so other states use it as a fallback + * to get out of exceptional situations, e.g. the flag carrier disconnecting. + */ + protected void forceRecover() { + if(isRecoverable()) { + this.flag.transition(new Respawning(this.flag, this.post, this.getLocation(), false, false)); + } + } + + @Override + public void onEvent(FlagCaptureEvent event) { + super.onEvent(event); + + // Not crazy about using an event for game logic, but this is by far the simplest way to do it + if(event.getNet().getRecoverableFlags().contains(this.flag.getDefinition())) { + this.recover(); + } + } + + protected boolean canSeeParticles(Player player) { + return true; + } + + @Override + public void tickLoaded() { + super.tickLoaded(); + + this.particleClock++; + + if(this.flag.getDefinition().showBeam()) { + Object packet = NMSHacks.particlesPacket("ITEM_CRACK", true, + this.getLocation().clone().add(0, 56, 0).toVector(), + new Vector(0.15, 24, 0.15), // radius on each axis of the particle ball + 0f, // initial horizontal velocity + 40, // number of particles + Material.WOOL.getId(), this.flag.getDyeColor().getWoolData()); + + for(Player player : this.flag.getMatch().getServer().getOnlinePlayers()) { + if(this.canSeeParticles(player)) NMSHacks.sendPacket(player, packet); + } + } + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/flag/state/State.java b/PGM/src/main/java/tc/oc/pgm/flag/state/State.java new file mode 100644 index 0000000..36f9add --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/flag/state/State.java @@ -0,0 +1,29 @@ +package tc.oc.pgm.flag.state; + +import org.bukkit.Location; +import tc.oc.pgm.match.MatchPlayer; +import tc.oc.pgm.match.ParticipantState; +import tc.oc.pgm.match.Party; +import tc.oc.pgm.flag.Post; +import tc.oc.pgm.teams.Team; + +import javax.annotation.Nullable; + +public interface State { + + boolean isCurrent(); + + boolean isCarrying(MatchPlayer player); + + boolean isCarrying(ParticipantState player); + + boolean isCarrying(Party team); + + boolean isAtPost(Post post); + + Post getPost(); + + @Nullable Team getController(); + + Iterable getProximityLocations(ParticipantState player); +} diff --git a/PGM/src/main/java/tc/oc/pgm/flag/state/Uncarried.java b/PGM/src/main/java/tc/oc/pgm/flag/state/Uncarried.java new file mode 100644 index 0000000..a3aaae1 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/flag/state/Uncarried.java @@ -0,0 +1,223 @@ +package tc.oc.pgm.flag.state; + +import javax.annotation.Nullable; + +import net.md_5.bungee.api.chat.TranslatableComponent; +import org.bukkit.Effect; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; +import org.bukkit.block.BlockState; +import org.bukkit.entity.ArmorStand; +import org.bukkit.entity.Projectile; +import org.bukkit.event.entity.EntityDamageByEntityEvent; +import org.bukkit.event.entity.EntityDamageEvent; +import org.bukkit.event.player.PlayerMoveEvent; +import tc.oc.commons.bukkit.util.BlockStateUtils; +import tc.oc.commons.bukkit.util.Materials; +import tc.oc.commons.bukkit.util.NMSHacks; +import tc.oc.commons.bukkit.util.materials.Banners; +import tc.oc.pgm.PGM; +import tc.oc.pgm.events.BlockTransformEvent; +import tc.oc.pgm.flag.Flag; +import tc.oc.pgm.flag.Post; +import tc.oc.pgm.flag.event.FlagPickupEvent; +import tc.oc.pgm.match.MatchPlayer; +import tc.oc.pgm.match.Party; + +/** + * Base class for flag states in which the banner is placed + * on the ground somewhere as a block + */ +public abstract class Uncarried extends Spawned { + + protected final Location location; + protected final BlockState oldBlock; + protected final BlockState oldBase; + protected ArmorStand labelEntity; + private @Nullable MatchPlayer pickingUp; + + public Uncarried(Flag flag, Post post, @Nullable Location location) { + super(flag, post); + if(location == null) location = flag.getReturnPoint(post); + this.location = new Location(location.getWorld(), + location.getBlockX() + 0.5, + location.getBlockY(), + location.getBlockZ() + 0.5, + location.getYaw(), + location.getPitch()); + + if(!flag.getMatch().getWorld().equals(this.location.getWorld())) { + throw new IllegalStateException("Tried to place flag in the wrong world"); + } + + Block block = this.location.getBlock(); + if(block.getType() == Material.STANDING_BANNER) { + // Banner may already be here at match start + this.oldBlock = BlockStateUtils.cloneWithMaterial(block, Material.AIR); + } else { + this.oldBlock = block.getState(); + } + this.oldBase = block.getRelative(BlockFace.DOWN).getState(); + } + + @Override + public Location getLocation() { + return this.location; + } + + protected void placeBanner() { + if(!this.flag.canDropOn(oldBase)) { + oldBase.getBlock().setType(Material.SEA_LANTERN, false); + } else if(Materials.isWater(oldBase.getType())) { + oldBase.getBlock().setType(Material.ICE, false); + } + + if(!Banners.placeStanding(this.location, this.flag.getBannerMeta())) { + PGM.get().getRootMapLogger().severe("Failed to place flag at " + location); + forceRecover(); + return; + } + + this.labelEntity = this.location.getWorld().spawn(this.location.clone().add(0, 0.7, 0), ArmorStand.class); + this.labelEntity.setVisible(false); + this.labelEntity.setGravity(false); + this.labelEntity.setRemoveWhenFarAway(false); + this.labelEntity.setSmall(true); + this.labelEntity.setArms(false); + this.labelEntity.setBasePlate(false); + this.labelEntity.setCustomName(this.flag.getColoredName()); + this.labelEntity.setCustomNameVisible(true); + NMSHacks.enableArmorSlots(this.labelEntity, false); + } + + protected void breakBanner() { + this.labelEntity.remove(); + oldBase.update(true, false); + oldBlock.update(true, false); + } + + @Override + public void enterState() { + super.enterState(); + this.placeBanner(); + this.flag.playFlareEffect(); + } + + @Override + public void leaveState() { + this.flag.playFlareEffect(); + this.breakBanner(); + super.leaveState(); + } + + @Override + public boolean isCarrying(MatchPlayer player) { + // This allows CarryingFlagFilter to match and cancel the pickup before it actually happens + return player == this.pickingUp || super.isCarrying(player); + } + + @Override + public boolean isCarrying(Party party) { + return (this.pickingUp != null && party == this.pickingUp.getParty()) || super.isCarrying(party); + } + + protected boolean pickupFlag(MatchPlayer carrier) { + try { + this.pickingUp = carrier; + FlagPickupEvent event = new FlagPickupEvent(this.flag, carrier, this.location); + this.flag.getMatch().getPluginManager().callEvent(event); + if(event.isCancelled()) return false; + } + finally { + this.pickingUp = null; + } + + this.flag.playStatusSound(Flag.PICKUP_SOUND_OWN, Flag.PICKUP_SOUND); + this.flag.touch(carrier.getParticipantState()); + + this.flag.transition(new Carried(this.flag, this.post, carrier, this.location)); + + return true; + } + + protected boolean inPickupRange(Location playerLoc) { + Location flagLoc = this.getLocation(); + if(playerLoc.getY() < flagLoc.getY() + 2 && playerLoc.getY() >= flagLoc.getY() - 2) { + double dx = playerLoc.getX() - flagLoc.getX(); + double dz = playerLoc.getZ() - flagLoc.getZ(); + + if(dx * dx + dz * dz <= 1) { + return true; + } + } + + return false; + } + + protected boolean canPickup(MatchPlayer player) { + if(this.pickingUp != null) return false; // Prevent infinite recursion + + if(flag.getMatch() + .features() + .all(Flag.class) + .anyMatch(flag -> flag.isCarrying(player))) { + + return false; + } + + return this.flag.canPickup(player, this.post); + } + + @Override + public void onEvent(PlayerMoveEvent event) { + super.onEvent(event); + MatchPlayer player = this.flag.getMatch().getPlayer(event.getPlayer()); + if(player == null || !player.canInteract() || player.getBukkit().isDead()) return; + + if(this.inPickupRange(player.getBukkit().getLocation()) && this.canPickup(player)) { + this.pickupFlag(player); + } + } + + @Override + public void onEvent(BlockTransformEvent event) { + super.onEvent(event); + + Block block = event.getOldState().getBlock(); + Block flagBlock = this.location.getBlock(); + + if(block.equals(flagBlock) || block.equals(flagBlock.getRelative(BlockFace.UP))) { + event.setCancelled(true, new TranslatableComponent("match.flag.cannotBreak")); + } else if(block.equals(flagBlock.getRelative(BlockFace.DOWN))) { + event.setCancelled(true, new TranslatableComponent("match.flag.cannotBreakBlockUnder")); + } + } + + @Override + public void onEvent(EntityDamageEvent event) { + super.onEvent(event); + + if(event.getEntity() == this.labelEntity) { + event.setCancelled(true); + + if(event instanceof EntityDamageByEntityEvent && ((EntityDamageByEntityEvent) event).getDamager() instanceof Projectile) { + ((EntityDamageByEntityEvent) event).getDamager().remove(); + } + } + } + + @Override + public void tickLoaded() { + super.tickLoaded(); + + if(this.particleClock % 10 == 0) { + this.flag.getMatch().getWorld().playEffect(this.getLocation().clone().add(0, 1, 0), + Effect.PORTAL, + 0, 0, + 0, 0, 0, + 1, 8, 64); + } + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/freeze/Freeze.java b/PGM/src/main/java/tc/oc/pgm/freeze/Freeze.java new file mode 100644 index 0000000..56364e6 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/freeze/Freeze.java @@ -0,0 +1,159 @@ +package tc.oc.pgm.freeze; + +import javax.annotation.Nullable; +import javax.inject.Inject; +import javax.inject.Singleton; + +import com.sk89q.minecraft.util.commands.CommandException; +import net.md_5.bungee.api.ChatColor; +import net.md_5.bungee.api.chat.BaseComponent; +import net.md_5.bungee.api.chat.TranslatableComponent; +import org.bukkit.Location; +import org.bukkit.Sound; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Player; +import org.bukkit.entity.TNTPrimed; +import tc.oc.commons.bukkit.channels.AdminChannel; +import tc.oc.commons.bukkit.chat.Audiences; +import tc.oc.commons.bukkit.chat.BukkitSound; +import tc.oc.commons.bukkit.chat.NameStyle; +import tc.oc.commons.bukkit.chat.PlayerComponent; +import tc.oc.commons.bukkit.commands.CommandUtils; +import tc.oc.commons.bukkit.freeze.FrozenPlayer; +import tc.oc.commons.bukkit.freeze.PlayerFreezer; +import tc.oc.commons.bukkit.nick.Identity; +import tc.oc.commons.bukkit.nick.IdentityProvider; +import tc.oc.commons.bukkit.util.OnlinePlayerMapAdapter; +import tc.oc.commons.core.chat.Audience; +import tc.oc.commons.core.chat.Component; +import tc.oc.commons.core.chat.Components; +import tc.oc.commons.core.commands.ComponentCommandException; +import tc.oc.commons.core.plugin.PluginFacet; + +@Singleton +public class Freeze implements PluginFacet { + + public static final String PERMISSION = "projectares.freeze"; + + private static final BukkitSound FREEZE_SOUND = new BukkitSound(Sound.ENTITY_ENDERDRAGON_GROWL, 1f, 1f); + private static final BukkitSound THAW_SOUND = new BukkitSound(Sound.ENTITY_ENDERDRAGON_GROWL, 1f, 2f); + + private final IdentityProvider identityProvider; + private final PlayerFreezer playerFreezer; + private final Audiences audiences; + private final FreezeConfig config; + private final OnlinePlayerMapAdapter frozenPlayers; + private final AdminChannel adminChannel; + + @Inject Freeze(IdentityProvider identityProvider, PlayerFreezer playerFreezer, Audiences audiences, FreezeConfig config, OnlinePlayerMapAdapter frozenPlayers, AdminChannel adminChannel) { + this.identityProvider = identityProvider; + this.playerFreezer = playerFreezer; + this.audiences = audiences; + this.config = config; + this.frozenPlayers = frozenPlayers; + this.adminChannel = adminChannel; + } + + @Override + public void enable() { + frozenPlayers.enable(); + } + + @Override + public void disable() { + frozenPlayers.disable(); + } + + public boolean enabled() { + return config.enabled(); + } + + public boolean isFrozen(Entity player) { + return player instanceof Player && frozenPlayers.containsKey(player); + } + + public void setFrozen(@Nullable CommandSender freezer, Player freezee, boolean frozen) throws CommandException { + if(!freezee.equals(freezer) && freezee.hasPermission("projectares.freeze.exempt")) { + throw new ComponentCommandException(new TranslatableComponent( + "command.freeze.exempt", + new PlayerComponent(identityProvider.currentIdentity(freezee), NameStyle.VERBOSE) + )); + } + + final Identity freezerIdentity = identityProvider.createIdentity(freezer); + final Audience freezeeAudience = audiences.get(freezee); + + final FrozenPlayer frozenPlayer = frozenPlayers.get(freezee); + if(frozen && frozenPlayer == null) { + frozenPlayers.put(freezee, playerFreezer.freeze(freezee)); + + final BaseComponent freezeeMessage = new Component( + new TranslatableComponent( + "freeze.frozen", + new PlayerComponent(freezerIdentity, NameStyle.FANCY) + ), + ChatColor.RED + ); + + freezeeAudience.playSound(FREEZE_SOUND); + freezeeAudience.sendWarning(freezeeMessage, false); + freezeeAudience.showTitle(Components.blank(), freezeeMessage, 5, 9999, 5); + + removeEntities(freezee.getLocation(), config.tntVictimRadius()); + + if(freezer instanceof Player) { + removeEntities(((Player) freezer).getLocation(), config.tntSenderRadius()); + } + + adminChannel.broadcast(CommandUtils.getDisplayName(freezer) + + ChatColor.RED + " froze " + + CommandUtils.getDisplayName(freezee)); + } else if(!frozen && frozenPlayer != null) { + frozenPlayer.thaw(); + frozenPlayers.remove(freezee); + + freezeeAudience.hideTitle(); + freezeeAudience.playSound(THAW_SOUND); + freezeeAudience.sendMessage(new Component( + new TranslatableComponent( + "freeze.unfrozen", + new PlayerComponent(freezerIdentity, NameStyle.FANCY) + ), + ChatColor.GREEN + )); + + adminChannel.broadcast(CommandUtils.getDisplayName(freezer) + + ChatColor.RED + " unfroze " + + CommandUtils.getDisplayName(freezee)); + } + } + + // Borrowed from WorldEdit + private void removeEntities(Location origin, double radius) { + if(radius <= 0) return; + + double radiusSq = radius * radius; + for(Entity ent : origin.getWorld().getEntities()) { + if(origin.distanceSquared(ent.getLocation()) > radiusSq) + continue; + + if(ent instanceof TNTPrimed) { + ent.remove(); + } + } + } + + /** Toggle the player's frozen status. + * @return Boolean indicating whether the player is now frozen or not. + */ + public boolean toggleFrozen(CommandSender freezer, Player freezee) throws CommandException { + if(enabled()) { + boolean frozen = !isFrozen(freezee); + setFrozen(freezer, freezee, frozen); + return frozen; + } else { + return false; + } + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/freeze/FreezeCommands.java b/PGM/src/main/java/tc/oc/pgm/freeze/FreezeCommands.java new file mode 100644 index 0000000..9464170 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/freeze/FreezeCommands.java @@ -0,0 +1,50 @@ +package tc.oc.pgm.freeze; + +import javax.inject.Inject; + +import com.sk89q.minecraft.util.commands.Command; +import com.sk89q.minecraft.util.commands.CommandContext; +import com.sk89q.minecraft.util.commands.CommandException; +import com.sk89q.minecraft.util.commands.CommandPermissions; +import net.md_5.bungee.api.chat.TranslatableComponent; +import org.bukkit.command.CommandSender; +import tc.oc.commons.bukkit.chat.Audiences; +import tc.oc.commons.bukkit.commands.UserFinder; +import tc.oc.minecraft.scheduler.MainThreadExecutor; +import tc.oc.commons.core.commands.CommandFutureCallback; +import tc.oc.commons.core.commands.Commands; +import tc.oc.commons.core.commands.ComponentCommandException; + +public class FreezeCommands implements Commands { + + private final Freeze freeze; + private final UserFinder userFinder; + private final MainThreadExecutor executor; + private final Audiences audiences; + + @Inject FreezeCommands(Freeze freeze, UserFinder userFinder, MainThreadExecutor executor, Audiences audiences) { + this.freeze = freeze; + this.userFinder = userFinder; + this.executor = executor; + this.audiences = audiences; + } + + @Command( + aliases = { "freeze", "f" }, + usage = "", + desc = "Freeze a player", + min = 1, + max = 1 + ) + @CommandPermissions(Freeze.PERMISSION) + public void freeze(final CommandContext args, final CommandSender sender) throws CommandException { + if(!freeze.enabled()) { + throw new ComponentCommandException(new TranslatableComponent("command.freeze.notEnabled")); + } + + executor.callback( + userFinder.findLocalPlayer(sender, args, 0), + CommandFutureCallback.onSuccess(sender, args, response -> freeze.toggleFrozen(sender, response.player())) + ); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/freeze/FreezeConfig.java b/PGM/src/main/java/tc/oc/pgm/freeze/FreezeConfig.java new file mode 100644 index 0000000..f96c9fd --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/freeze/FreezeConfig.java @@ -0,0 +1,28 @@ +package tc.oc.pgm.freeze; + +import javax.inject.Inject; + +import org.bukkit.configuration.Configuration; +import org.bukkit.configuration.ConfigurationSection; + +import static com.google.common.base.Preconditions.checkNotNull; + +public class FreezeConfig { + private final ConfigurationSection config; + + @Inject FreezeConfig(Configuration root) { + this.config = checkNotNull(root.getConfigurationSection("freeze"), "Missing freeze configuration section"); + } + + public boolean enabled() { + return config.getBoolean("enabled", false); + } + + public double tntVictimRadius() { + return config.getDouble("remove-tnt.victim-radius", -1); + } + + public double tntSenderRadius() { + return config.getDouble("remove-tnt.sender-radius", -1); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/freeze/FreezeListener.java b/PGM/src/main/java/tc/oc/pgm/freeze/FreezeListener.java new file mode 100644 index 0000000..7d5cef4 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/freeze/FreezeListener.java @@ -0,0 +1,181 @@ +package tc.oc.pgm.freeze; + +import java.util.Collections; +import javax.inject.Inject; + +import com.sk89q.minecraft.util.commands.CommandException; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.block.BlockBreakEvent; +import org.bukkit.event.block.BlockPlaceEvent; +import org.bukkit.event.entity.EntityDamageByEntityEvent; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.player.PlayerBucketEmptyEvent; +import org.bukkit.event.player.PlayerBucketFillEvent; +import org.bukkit.event.player.PlayerDropItemEvent; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.event.player.PlayerMoveEvent; +import org.bukkit.event.vehicle.VehicleDamageEvent; +import org.bukkit.event.vehicle.VehicleEnterEvent; +import org.bukkit.event.vehicle.VehicleExitEvent; +import org.bukkit.event.vehicle.VehicleMoveEvent; +import org.bukkit.inventory.ItemFlag; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.util.Vector; +import tc.oc.commons.bukkit.localization.Translations; +import tc.oc.commons.core.commands.CommandExceptionHandler; +import tc.oc.commons.core.plugin.PluginFacet; +import tc.oc.pgm.events.ObserverInteractEvent; +import tc.oc.commons.bukkit.event.ObserverKitApplyEvent; + +public class FreezeListener implements Listener, PluginFacet { + + private final Freeze freeze; + private final CommandExceptionHandler.Factory exceptionHandlerFactory; + + @Inject FreezeListener(Freeze freeze, CommandExceptionHandler.Factory exceptionHandlerFactory) { + this.freeze = freeze; + this.exceptionHandlerFactory = exceptionHandlerFactory; + } + + @EventHandler(priority = EventPriority.LOW, ignoreCancelled = true) + public void onPlayerInteractEntity(final ObserverInteractEvent event) { + if(event.getPlayer().isDead()) return; + + if(freeze.isFrozen(event.getPlayer().getBukkit())) { + event.setCancelled(true); + + } else if(freeze.enabled()) { + if(event.getClickedItem() != null && + event.getClickedItem().getType() == Material.ICE && + event.getPlayer().getBukkit().hasPermission(Freeze.PERMISSION) && + event.getClickedPlayer() != null) { + + event.setCancelled(true); + + try { + freeze.toggleFrozen(event.getPlayer().getBukkit(), event.getClickedPlayer().getBukkit()); + } catch(CommandException e) { + exceptionHandlerFactory + .create(event.getPlayer().getBukkit()) + .handleException(e, null, null); + } + } + } + } + + @EventHandler + public void giveKit(final ObserverKitApplyEvent event) { + if(event.getPlayer().hasPermission(Freeze.PERMISSION)) { + ItemStack item = new ItemStack(Material.ICE); + ItemMeta meta = item.getItemMeta(); + meta.addItemFlags(ItemFlag.values()); + meta.setDisplayName(Translations.get().t("freeze.itemName", event.getPlayer())); + meta.setLore(Collections.singletonList(Translations.get().t("freeze.itemDescription", event.getPlayer()))); + item.setItemMeta(meta); + + event.getPlayer().getInventory().setItem(6, item); + } + } + + @EventHandler(priority = EventPriority.LOW, ignoreCancelled = true) + public void onPlayerMove(final PlayerMoveEvent event) { + if(freeze.isFrozen(event.getPlayer())) { + Location old = event.getFrom(); + old.setPitch(event.getTo().getPitch()); + old.setYaw(event.getTo().getYaw()); + event.setTo(old); + } + } + + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + public void onVehicleMove(final VehicleMoveEvent event) { + if(!event.getVehicle().isEmpty() && freeze.isFrozen(event.getVehicle().getPassenger())) { + event.getVehicle().setVelocity(new Vector(0, 0, 0)); + } + } + + @EventHandler(priority = EventPriority.LOW, ignoreCancelled = true) + public void onVehicleEnter(final VehicleEnterEvent event) { + if(freeze.isFrozen(event.getEntered())) { + event.setCancelled(true); + } + } + + @EventHandler(priority = EventPriority.LOW, ignoreCancelled = true) + public void onVehicleExit(final VehicleExitEvent event) { + if(freeze.isFrozen(event.getExited())) { + event.setCancelled(true); + } + } + + @EventHandler(priority = EventPriority.LOW, ignoreCancelled = true) + public void onBlockBreak(final BlockBreakEvent event) { + if(freeze.isFrozen(event.getPlayer())) { + event.setCancelled(true); + } + } + + @EventHandler(priority = EventPriority.LOW, ignoreCancelled = true) + public void onBlockPlace(final BlockPlaceEvent event) { + if(freeze.isFrozen(event.getPlayer())) { + event.setCancelled(true); + } + } + + @EventHandler(priority = EventPriority.LOW, ignoreCancelled = true) + public void onBucketFill(final PlayerBucketFillEvent event) { + if(freeze.isFrozen(event.getPlayer())) { + event.setCancelled(true); + } + } + + @EventHandler(priority = EventPriority.LOW, ignoreCancelled = true) + public void onBucketEmpty(final PlayerBucketEmptyEvent event) { + if(freeze.isFrozen(event.getPlayer())) { + event.setCancelled(true); + } + } + + @EventHandler(priority = EventPriority.LOW) // ignoreCancelled doesn't seem to work well here + public void onPlayerInteract(final PlayerInteractEvent event) { + if(freeze.isFrozen(event.getPlayer())) { + event.setCancelled(true); + } + } + + @EventHandler(priority = EventPriority.LOW, ignoreCancelled = true) + public void onInventoryClick(final InventoryClickEvent event) { + if(event.getWhoClicked() instanceof Player) { + if(freeze.isFrozen(event.getWhoClicked())) { + event.setCancelled(true); + } + } + } + + @EventHandler(priority = EventPriority.LOW, ignoreCancelled = true) + public void onPlayerDropItem(final PlayerDropItemEvent event) { + if(freeze.isFrozen(event.getPlayer())) { + event.setCancelled(true); + } + } + + @EventHandler(priority = EventPriority.LOW, ignoreCancelled = true) + public void onEntityDamge(final EntityDamageByEntityEvent event) { + if(freeze.isFrozen(event.getDamager())) { + event.setCancelled(true); + } + } + + @EventHandler(priority = EventPriority.LOW, ignoreCancelled = true) + public void onVehicleDamage(final VehicleDamageEvent event) { + if(freeze.isFrozen(event.getAttacker())) { + event.setCancelled(true); + } + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/gamerules/GameRule.java b/PGM/src/main/java/tc/oc/pgm/gamerules/GameRule.java new file mode 100644 index 0000000..59bc83d --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/gamerules/GameRule.java @@ -0,0 +1,30 @@ +package tc.oc.pgm.gamerules; + +public enum GameRule { + DO_FIRE_TICK("doFireTick"), + DO_MOB_LOOT("doMobLoot"), + DO_TILE_DROPS("doTileDrops"), + MOB_GRIEFING("mobGriefing"), + NATURAL_REGENERATION("naturalRegeneration"), + DO_DAYLIGHT_CYCLE("doDaylightCycle"); + + private String value; + + GameRule(String value) { + this.value = value; + } + + public String getValue() { + return this.value; + } + + public static GameRule forName(String query) { + for (GameRule gamerule : values()) { + if (gamerule.getValue().equalsIgnoreCase(query)) { + return gamerule; + } + } + + return null; + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/gamerules/GameRulesMatchModule.java b/PGM/src/main/java/tc/oc/pgm/gamerules/GameRulesMatchModule.java new file mode 100644 index 0000000..85123f3 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/gamerules/GameRulesMatchModule.java @@ -0,0 +1,34 @@ +package tc.oc.pgm.gamerules; + +import java.util.Map; +import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableMap; + +import tc.oc.pgm.match.Match; +import tc.oc.pgm.match.MatchModule; +import tc.oc.pgm.mutation.Mutation; +import tc.oc.pgm.mutation.MutationMatchModule; + +public class GameRulesMatchModule extends MatchModule { + + private final Map gameRules; + + public GameRulesMatchModule(Match match, Map gameRules) { + super(match); + this.gameRules = Preconditions.checkNotNull(gameRules, "gamerules"); + if(MutationMatchModule.check(match, Mutation.UHC)) { + this.gameRules.put(GameRule.NATURAL_REGENERATION, Boolean.FALSE); + } + } + + @Override + public void load() { + for (Map.Entry gameRule : this.gameRules.entrySet()) { + this.match.getWorld().setGameRuleValue(gameRule.getKey().getValue(), gameRule.getValue().toString()); + } + } + + public ImmutableMap getGameRules() { + return ImmutableMap.copyOf(gameRules); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/gamerules/GameRulesModule.java b/PGM/src/main/java/tc/oc/pgm/gamerules/GameRulesModule.java new file mode 100644 index 0000000..dfb1e37 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/gamerules/GameRulesModule.java @@ -0,0 +1,65 @@ +package tc.oc.pgm.gamerules; + +import java.util.HashMap; +import java.util.Map; +import java.util.logging.Logger; + +import com.google.common.collect.ImmutableMap; +import org.jdom2.Document; +import org.jdom2.Element; +import tc.oc.pgm.map.MapModule; +import tc.oc.pgm.map.MapModuleContext; +import tc.oc.pgm.match.Match; +import tc.oc.pgm.match.MatchModuleFactory; +import tc.oc.pgm.module.ModuleDescription; +import tc.oc.pgm.mutation.MutationMapModule; +import tc.oc.pgm.xml.InvalidXMLException; + +@ModuleDescription(name="Gamerules", follows = MutationMapModule.class) +public class GameRulesModule implements MapModule, MatchModuleFactory { + + private Map gameRules; + + private GameRulesModule(Map gamerules) { + this.gameRules = gamerules; + } + + public GameRulesMatchModule createMatchModule(Match match) { + return new GameRulesMatchModule(match, this.gameRules); + } + + // --------------------- + // ---- XML Parsing ---- + // --------------------- + + public static GameRulesModule parse(MapModuleContext context, Logger logger, Document doc) throws InvalidXMLException { + Map gameRules = new HashMap<>(); + + for (Element gameRulesElement : doc.getRootElement().getChildren("gamerules")) { + for (Element gameRuleElement : gameRulesElement.getChildren()) { + GameRule gameRule = GameRule.forName(gameRuleElement.getName()); + String value = gameRuleElement.getValue(); + + if (gameRule == null) { + throw new InvalidXMLException(gameRuleElement.getName() + " is not a valid gamerule", gameRuleElement); + } + if (value == null) { + throw new InvalidXMLException("Missing value for gamerule " + gameRule.getValue(), gameRuleElement); + } else if (!(value.equalsIgnoreCase("true") || value.equalsIgnoreCase("false"))) { + throw new InvalidXMLException(gameRuleElement.getValue() + " is not a valid gamerule value", gameRuleElement); + } + if (gameRules.containsKey(gameRule)){ + throw new InvalidXMLException(gameRule.getValue() + " has already been specified", gameRuleElement); + } + + gameRules.put(gameRule, Boolean.valueOf(value)); + } + } + return new GameRulesModule(gameRules); + } + + public ImmutableMap getGameRules() { + return ImmutableMap.copyOf(this.gameRules); + } + +} diff --git a/PGM/src/main/java/tc/oc/pgm/ghostsquadron/GhostSquadron.java b/PGM/src/main/java/tc/oc/pgm/ghostsquadron/GhostSquadron.java new file mode 100644 index 0000000..72d15c6 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/ghostsquadron/GhostSquadron.java @@ -0,0 +1,38 @@ +package tc.oc.pgm.ghostsquadron; + +import java.util.Set; + +import org.bukkit.Material; + +import com.google.common.collect.ImmutableSet; + +public class GhostSquadron { + public static final int REVEAL_STANDARD_DURATION = 20; // ticks + public static final int MAX_FIRE_TICKS = 40; // ticks + + public static final Set ALLOWED_DROPS = ImmutableSet.builder() + .add(Material.ARROW) + .add(Material.FIREBALL) + .build(); + + public static final Set BREAKABLE_BLOCKS = ImmutableSet.builder() + .add(Material.WEB) + .build(); + + public static final int ARROW_REVEAL_DURATION = 15; // ticks + + public static final double LANDMINE_ACTIVATION_DISTANCE = 1.5; + public static final double LANDMINE_ACTIVATION_DISTANCE_SQ = Math.pow(LANDMINE_ACTIVATION_DISTANCE, 2); + public static final int LANDMINE_SPACING = 1; + + public static final double SPIDEY_SENSE_RADIUS = 5.0; + public static final double SPIDER_SENSE_RADIUS_SQ = Math.pow(SPIDEY_SENSE_RADIUS, 2); + public static final int SPIDEY_SENSE_COOLDOWN = 3000; // milliseconds + + public static final double TRACKER_FOOTSTEP_SPACING = 1.0; + public static final double TRACKER_FOOTSTEP_DY = 0.2; + public static final int TRACKER_REVEAL_DURATION = 10; + + private GhostSquadron() { + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/ghostsquadron/GhostSquadronMatchModule.java b/PGM/src/main/java/tc/oc/pgm/ghostsquadron/GhostSquadronMatchModule.java new file mode 100644 index 0000000..6c08635 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/ghostsquadron/GhostSquadronMatchModule.java @@ -0,0 +1,408 @@ +package tc.oc.pgm.ghostsquadron; + +import static com.google.common.base.Preconditions.checkNotNull; + +import java.util.Date; +import java.util.Iterator; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Optional; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Maps; +import org.bukkit.*; +import org.bukkit.block.BlockFace; +import org.bukkit.entity.*; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.block.Action; +import org.bukkit.event.entity.*; +import org.bukkit.event.entity.EntityDamageEvent.DamageCause; +import org.bukkit.event.player.*; +import org.bukkit.inventory.ItemStack; +import org.bukkit.potion.PotionEffect; +import org.bukkit.potion.PotionEffectType; +import org.bukkit.scheduler.BukkitTask; +import tc.oc.api.docs.PlayerId; +import tc.oc.api.docs.UserId; +import tc.oc.pgm.events.ListenerScope; +import tc.oc.pgm.match.Competitor; +import tc.oc.pgm.match.Match; +import tc.oc.pgm.match.MatchPlayer; +import tc.oc.pgm.PGMTranslations; +import tc.oc.pgm.classes.ClassMatchModule; +import tc.oc.pgm.classes.PlayerClass; +import tc.oc.pgm.ghostsquadron.RevealTask.RevealEntry; +import tc.oc.pgm.match.MatchModule; +import tc.oc.pgm.match.MatchScope; +import tc.oc.pgm.utils.MatchPlayers; + +@ListenerScope(MatchScope.RUNNING) +public class GhostSquadronMatchModule extends MatchModule implements Listener { + BukkitTask mainTask; + BukkitTask revealTask; + final ClassMatchModule classMatchModule; + public final Map landmines = Maps.newHashMap(); + public final Map landmineTeams = Maps.newHashMap(); + public final Map spideySenses = Maps.newHashMap(); + final Map walkDistance = Maps.newHashMap(); + + public final Map revealMap = Maps.newHashMap(); + + // classes + final Optional trackerClass; + final Optional spiderClass; + final Optional leprechaunClass; + final Optional demoClass; + // final PlayerClass ninjaClass; + + public GhostSquadronMatchModule(Match match, ClassMatchModule classMatchModule) { + super(match); + this.classMatchModule = checkNotNull(classMatchModule, "class match module"); + this.trackerClass = classMatchModule.findClass("Tracker"); + this.spiderClass = classMatchModule.findClass("Spider"); + this.leprechaunClass = classMatchModule.findClass("Leprechaun"); + this.demoClass = classMatchModule.findClass("Demo"); + // this.ninjaClass = checkNotNull(classMatchModule.getPlayerClass("Ninja"), "Ninja class not found"); + } + + @Override + public void enable() { + GhostSquadronTask task = new GhostSquadronTask(this.match, this, this.classMatchModule); + this.mainTask = Bukkit.getScheduler().runTaskTimer(this.match.getPlugin(), task, 0, 10); + this.revealTask = Bukkit.getScheduler().runTaskTimer(this.match.getPlugin(), new RevealTask(this), 0, 1); + } + + @Override + public void disable() { + this.mainTask.cancel(); + this.revealTask.cancel(); + } + + /* + * GENERAL + */ + @EventHandler + public void cancelDrop(final PlayerDropItemEvent event) { + Material hand = event.getPlayer().getItemInHand().getType(); + if(!GhostSquadron.ALLOWED_DROPS.contains(hand)) { + MatchPlayer player = this.match.getPlayer(event.getPlayer()); + if(MatchPlayers.canInteract(player)) { + event.setCancelled(true); + } + } + } + + @EventHandler + public void cancelItemSpawn(final ItemSpawnEvent event) { + Material hand = event.getEntity().getItemStack().getType(); + if(!GhostSquadron.ALLOWED_DROPS.contains(hand)) { + event.setCancelled(true); + } + } + + @EventHandler + public void cancelPickup(final PlayerPickupItemEvent event) { + event.setCancelled(true); + } + + @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true) + public void noExplosionBlockDamage(EntityExplodeEvent event) { + event.blockList().clear(); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void enforceFireTickLimit(EntityDamageEvent event) { + event.getEntity().setFireTicks(Math.min(event.getEntity().getFireTicks(), GhostSquadron.MAX_FIRE_TICKS)); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void resetRevealTicks(PlayerDeathEvent event) { + this.revealMap.remove(event.getEntity()); + } + + private void reveal(Player player) { + this.reveal(player, GhostSquadron.REVEAL_STANDARD_DURATION); + } + + private void reveal(Player player, int ticks) { + RevealEntry entry = this.revealMap.get(player); + if(entry == null) entry = new RevealEntry(); + + entry.revealTicks = ticks; + + for(PotionEffect e : player.getActivePotionEffects()) { + if(e.getType().equals(PotionEffectType.INVISIBILITY)) { + entry.potionTicks = e.getDuration(); + } + } + + player.removePotionEffect(PotionEffectType.INVISIBILITY); + + this.revealMap.put(player, entry); + } + + /* + * ARCHER + */ + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void revealOnArrow(final EntityDamageByEntityEvent event) { + if(event.getCause() == DamageCause.PROJECTILE && event.getDamager() instanceof Arrow && event.getEntity() instanceof Player) { + this.reveal((Player) event.getEntity(), GhostSquadron.ARROW_REVEAL_DURATION); + } + } + + /* + * TRACKER + */ + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void trackerMove(final PlayerMoveEvent event) { + MatchPlayer enemy = this.getMatch().getPlayer(event.getPlayer()); + if(!MatchPlayers.canInteract(enemy)) return; + + ImmutableList.builder(); + + double distance = event.getFrom().distance(event.getTo()); + + final Double walkedRaw = this.walkDistance.get(event.getPlayer()); + final double walkedStart = walkedRaw != null ? walkedRaw.doubleValue() : 0; + final int stepStart = (int) Math.floor(walkedStart / GhostSquadron.TRACKER_FOOTSTEP_SPACING); + + final double walkedEnd = walkedStart + distance; + final int stepEnd = (int) Math.floor(walkedEnd / GhostSquadron.TRACKER_FOOTSTEP_SPACING); + + this.walkDistance.put(event.getPlayer(), walkedEnd); + + Location normal = event.getTo().clone().subtract(event.getFrom()); + normal.multiply(1.0 / normal.length()); + + for(int step = stepStart; step < stepEnd; step++) { + double distanceFromStart = (step + 1) * GhostSquadron.TRACKER_FOOTSTEP_SPACING - walkedStart; + Location stepLoc = normal.clone().multiply(distanceFromStart).add(event.getFrom()).add(0, GhostSquadron.TRACKER_FOOTSTEP_DY, 0); + + for(UserId userId : this.classMatchModule.getClassMembers(this.trackerClass)) { + MatchPlayer tracker = this.match.getPlayer(userId); + if(MatchPlayers.canInteract(tracker) && tracker.getParty() != enemy.getParty()) { + tracker.getBukkit().playEffect(stepLoc, Effect.FOOTSTEP, 0, 0, 0f, 0f, 0f, 0f, 1, 50); + } + } + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void clearWalkDistanceOnDeath(PlayerDeathEvent event) { + // players are killed when leaving team or quitting, so this covers every instance + this.walkDistance.remove(event.getEntity()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void trackerMelee(final EntityDamageByEntityEvent event) { + if(event.getDamager() instanceof Player && event.getEntity() instanceof Player) { + MatchPlayer damager = this.getMatch().getPlayer((Player) event.getDamager()); + MatchPlayer damaged = this.getMatch().getPlayer((Player) event.getEntity()); + + if(damager != null && damaged != null && this.isClass(damager, this.trackerClass) && damager.getParty() != damaged.getParty()) { + ItemStack hand = damager.getBukkit().getItemInHand(); + if(hand != null && hand.getType() == Material.COMPASS) { + this.reveal(damaged.getBukkit(), GhostSquadron.TRACKER_REVEAL_DURATION); + } + } + } + } + + /* + * LEPRECHAUN + */ + @EventHandler + public void dontPickupExp(final PlayerPickupExperienceEvent event) { + event.setCancelled(true); + } + + @EventHandler + public void fastLiquids(final PlayerMoveEvent event) { + final MatchPlayer player = this.getMatch().getParticipant(event.getPlayer()); + if(player != null && this.isClass(player, this.leprechaunClass)) { + if(event.getTo().getBlock().isLiquid()) { + event.getPlayer().setAllowFlight(true); + event.getPlayer().setFlying(true); + } else { + event.getPlayer().setFlying(false); + } + } + } + + /* + * DEMO + */ + @EventHandler + public void landminePlace(final PlayerInteractEvent event) { + Player player = event.getPlayer(); + MatchPlayer mPlayer = match.getPlayer(player); + if(!MatchPlayers.canInteract(mPlayer) || !this.isClass(mPlayer, this.demoClass)) return; + + final ItemStack item = event.getPlayer().getItemInHand(); + if(event.getAction() == Action.RIGHT_CLICK_BLOCK && item != null && item.getType() == Material.TNT) { + if(event.getClickedBlock().getRelative(BlockFace.UP).getType() != Material.AIR) { + mPlayer.sendWarning(ChatColor.RED + PGMTranslations.t("match.ghostSquadron.landmine.invalidLocation", mPlayer), true); + return; + } + + if(this.landmines.containsKey(event.getClickedBlock().getLocation())) { + mPlayer.sendWarning(ChatColor.RED + PGMTranslations.t("match.ghostSquadron.landmine.alreadyExists", mPlayer), true); + return; + } + + Location place = event.getClickedBlock().getLocation().add(.5, 0, .5); + for(Location loc : this.landmines.keySet()) { + boolean xClose = Math.abs(place.getX() - loc.getX()) <= GhostSquadron.LANDMINE_SPACING; + boolean zClose = Math.abs(place.getZ() - loc.getZ()) <= GhostSquadron.LANDMINE_SPACING; + if(xClose && zClose) { + mPlayer.sendWarning(ChatColor.RED + PGMTranslations.t("match.ghostSquadron.landmine.tooClose", mPlayer), true); + return; + } + } + + event.setCancelled(true); + this.landmines.put(place, mPlayer.getPlayerId()); + this.landmineTeams.put(place, mPlayer.getCompetitor()); + + if(item.getAmount() > 1) { + item.setAmount(item.getAmount() - 1); + } else { + event.getPlayer().setItemInHand(null); + } + + player.sendMessage(ChatColor.GREEN + PGMTranslations.t("ghostSquadron.landminePlanted", mPlayer)); + } + } + + @EventHandler + public void landmineExplode(final PlayerMoveEvent event) { + MatchPlayer player = this.getMatch().getPlayer(event.getPlayer()); + if(!MatchPlayers.canInteract(player)) return; + + Location to = event.getTo(); + Iterator> iterator = this.landmines.entrySet().iterator(); + + while(iterator.hasNext()) { + Map.Entry entry = iterator.next(); + Location landmine = entry.getKey(); + MatchPlayer placer = this.getMatch().getPlayer(entry.getValue()); + + if(placer == null || !placer.isParticipating()) { + iterator.remove(); + continue; + } + + Competitor placerTeam = this.landmineTeams.get(landmine); + if(placerTeam == player.getParty()) continue; + + if(to.distanceSquared(landmine) < GhostSquadron.LANDMINE_ACTIVATION_DISTANCE_SQ) { + TNTPrimed tnt = (TNTPrimed) landmine.getWorld().spawnEntity(landmine.clone().add(0, 1, 0), EntityType.PRIMED_TNT); + tnt.setFuseTicks(0); + tnt.setYield(1); + + this.reveal(player.getBukkit()); + getMatch().callEvent(new ExplosionPrimeByEntityEvent(tnt, placer.getBukkit())); + iterator.remove(); + this.landmineTeams.remove(landmine); + } + } + } + + /* + * SPIDER + */ + public void spideySense(final MatchPlayer player) { + UserId userId = player.getPlayerId(); + + Date when = this.spideySenses.get(userId); + Date now = new Date(); + + if(when == null || now.getTime() > when.getTime() + GhostSquadron.SPIDEY_SENSE_COOLDOWN) { + this.spideySenses.put(userId, now); + + player.getBukkit().addPotionEffect(new PotionEffect(PotionEffectType.NIGHT_VISION, 4 * 20, 0), true); + player.getBukkit().addPotionEffect(new PotionEffect(PotionEffectType.SPEED, 4 * 20, 1), true); + + player.getBukkit().playSound(player.getBukkit().getLocation(), Sound.ENTITY_SPIDER_AMBIENT, 5, 0); + player.getBukkit().playSound(player.getBukkit().getLocation(), Sound.ENTITY_SPIDER_AMBIENT, 5, 0.25f); + player.getBukkit().playSound(player.getBukkit().getLocation(), Sound.ENTITY_SPIDER_AMBIENT, 5, 0.5f); + } + } + + @EventHandler + public void webBow(final EntityShootBowEvent event) { + if(!(event.getEntity() instanceof Player)) return; + Player player = (Player) event.getEntity(); + if(!this.isClass(this.getMatch().getPlayer(player), spiderClass)) return; + + FallingBlock web = event.getEntity().getWorld().spawnFallingBlock(event.getProjectile().getLocation(), Material.WEB, (byte) 0); + web.setDropItem(false); + web.setVelocity(event.getProjectile().getVelocity()); + event.setProjectile(web); + } + + @EventHandler + public void webLand(final EntityChangeBlockEvent event) { + if(!(event.getEntity() instanceof FallingBlock)) return; + FallingBlock block = (FallingBlock) event.getEntity(); + if(block.getMaterial() != Material.WEB) return; + + event.getEntity().getLocation().getBlock().setType(Material.WEB); + } + + /* + * NINJA - Temporarily disabled + */ + + /* + @EventHandler + public void hookPlayer(final PlayerFishEvent event) { + if(event.getState() == PlayerFishEvent.State.FISHING || event.getState() == PlayerFishEvent.State.FAILED_ATTEMPT) return; + + MatchPlayer caster = this.match.getPlayer(event.getPlayer()); + if (!this.isClass(caster, this.ninjaClass)) return; + + Location center = event.getHook().getLocation(); + Vector pullTowards = event.getPlayer().getLocation().toVector(); + + for(Player player : event.getPlayer().getWorld().getPlayers()) { + if(player == event.getPlayer()) continue; + if(player.getLocation().distance(center) > 5) continue; + MatchPlayer matchPlayer = this.match.getPlayer(player); + if(!matchPlayer.canInteract() || matchPlayer.getTeam() == caster.getTeam()) continue; + + Vector velocity = pullTowards.subtract(player.getLocation().toVector()).divide(new Vector(3, 3, 3)); + + double MIN = -2; + double MAX = 2; + velocity.setX(clamp(MIN, MAX, velocity.getX())); + velocity.setY(clamp(MIN, MAX, velocity.getY())); + velocity.setZ(clamp(MIN, MAX, velocity.getZ())); + + player.setVelocity(velocity); + this.reveal(player); + } + + event.getHook().remove(); + } + */ + + private static double clamp(double min, double max, double def) { + if(def < min) return min; + if(def > max) return max; + return def; + } + + private boolean isClass(MatchPlayer player, Optional playerClass) { + return playerClass.isPresent() && isClass(player, playerClass.get()); + } + + private boolean isClass(MatchPlayer player, PlayerClass playerClass) { + return classMatchModule.playingClass(player) + .filter(playerClass::equals) + .isPresent(); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/ghostsquadron/GhostSquadronModule.java b/PGM/src/main/java/tc/oc/pgm/ghostsquadron/GhostSquadronModule.java new file mode 100644 index 0000000..fb7b344 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/ghostsquadron/GhostSquadronModule.java @@ -0,0 +1,48 @@ +package tc.oc.pgm.ghostsquadron; + +import java.util.Collections; +import java.util.Set; +import java.util.logging.Logger; + +import net.md_5.bungee.api.chat.BaseComponent; +import net.md_5.bungee.api.chat.TranslatableComponent; +import org.jdom2.Document; +import org.jdom2.Element; +import tc.oc.api.docs.virtual.MapDoc; +import tc.oc.pgm.blitz.BlitzModule; +import tc.oc.pgm.classes.ClassMatchModule; +import tc.oc.pgm.classes.ClassModule; +import tc.oc.pgm.map.MapModule; +import tc.oc.pgm.map.MapModuleContext; +import tc.oc.pgm.match.Match; +import tc.oc.pgm.match.MatchModuleFactory; +import tc.oc.pgm.module.ModuleDescription; + +@ModuleDescription(name = "Ghost Squadron", depends = { ClassModule.class }, follows = { BlitzModule.class }) +public class GhostSquadronModule implements MapModule, MatchModuleFactory { + + private static final BaseComponent GAME = new TranslatableComponent("match.scoreboard.gs.title"); + @Override + public BaseComponent getGameName(MapModuleContext context) { + return GAME; + } + + @Override + public Set getGamemodes(MapModuleContext context) { + return Collections.singleton(MapDoc.Gamemode.gs); + } + + @Override + public GhostSquadronMatchModule createMatchModule(Match match) { + return new GhostSquadronMatchModule(match, match.getMatchModule(ClassMatchModule.class)); + } + + public static GhostSquadronModule parse(MapModuleContext context, Logger logger, Document doc) { + Element ghostSquadronEl = doc.getRootElement().getChild("ghostsquadron"); + if(ghostSquadronEl == null) { + return null; + } else { + return new GhostSquadronModule(); + } + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/ghostsquadron/GhostSquadronTask.java b/PGM/src/main/java/tc/oc/pgm/ghostsquadron/GhostSquadronTask.java new file mode 100644 index 0000000..36d99b0 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/ghostsquadron/GhostSquadronTask.java @@ -0,0 +1,71 @@ +package tc.oc.pgm.ghostsquadron; + +import static com.google.common.base.Preconditions.checkNotNull; + +import java.util.Map; + +import org.bukkit.Effect; +import org.bukkit.Location; +import tc.oc.api.docs.PlayerId; +import tc.oc.api.docs.UserId; +import tc.oc.pgm.match.Match; +import tc.oc.pgm.match.MatchPlayer; +import tc.oc.pgm.classes.ClassMatchModule; + +public class GhostSquadronTask implements Runnable { + public GhostSquadronTask(Match match, GhostSquadronMatchModule matchModule, ClassMatchModule classMatchModule) { + this.match = checkNotNull(match, "match"); + this.matchModule = checkNotNull(matchModule, "ghost squadron match module"); + this.classMatchModule = checkNotNull(classMatchModule, "class match module"); + } + + @Override + public void run() { + for(UserId userId : this.classMatchModule.getClassMembers(this.matchModule.trackerClass)) { + MatchPlayer player = this.match.getPlayer(userId); + if(player == null) continue; + + MatchPlayer closestEnemy = player; + double closestRadiusSq = Double.MAX_VALUE; + + for(MatchPlayer enemy : this.matchModule.getMatch().getParticipatingPlayers()) { + if(enemy.getParty() == player.getParty()) continue; + + double radiusSq = enemy.getBukkit().getLocation().distanceSquared(player.getBukkit().getLocation()); + if(radiusSq < closestRadiusSq) { + closestEnemy = enemy; + closestRadiusSq = radiusSq; + } + } + + player.getBukkit().setCompassTarget(closestEnemy.getBukkit().getLocation()); + } + + for(Map.Entry entry : this.matchModule.landmines.entrySet()) { + MatchPlayer player = this.match.getPlayer(entry.getValue()); + Location loc = entry.getKey(); + if(player == null) continue; + + for(MatchPlayer enemy : this.match.getPlayers()) { + enemy.getBukkit().playEffect(loc.clone().add(0, .7, 0), Effect.VILLAGER_THUNDERCLOUD, 0, 0, 0f, 0f, 0f, 0f, 1, 3); + } + } + + for(UserId userId : this.classMatchModule.getClassMembers(this.matchModule.spiderClass)) { + MatchPlayer player = this.match.getPlayer(userId); + if(player == null) continue; + + for(MatchPlayer enemy : this.matchModule.getMatch().getParticipatingPlayers()) { + if(enemy.getParty() == player.getParty()) continue; + if(enemy.getBukkit().getLocation().distanceSquared(player.getBukkit().getLocation()) < GhostSquadron.SPIDER_SENSE_RADIUS_SQ) { + this.matchModule.spideySense(player); + break; + } + } + } + } + + final Match match; + final GhostSquadronMatchModule matchModule; + final ClassMatchModule classMatchModule; +} diff --git a/PGM/src/main/java/tc/oc/pgm/ghostsquadron/RevealTask.java b/PGM/src/main/java/tc/oc/pgm/ghostsquadron/RevealTask.java new file mode 100644 index 0000000..b48f31b --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/ghostsquadron/RevealTask.java @@ -0,0 +1,49 @@ +package tc.oc.pgm.ghostsquadron; + +import java.util.Iterator; +import java.util.Map; + +import org.bukkit.entity.Player; +import org.bukkit.potion.PotionEffectType; + +public class RevealTask implements Runnable { + public RevealTask(GhostSquadronMatchModule matchModule) { + this.matchModule = matchModule; + } + + @Override + public void run() { + for(Iterator> it = this.matchModule.revealMap.entrySet().iterator(); it.hasNext(); ) { + Map.Entry mapEntry = it.next(); + + Player player = mapEntry.getKey(); + RevealEntry entry = mapEntry.getValue(); + + entry.potionTicks--; + entry.revealTicks--; + + if(entry.potionTicks <= 0 || entry.revealTicks <= 0) { + it.remove(); + if(entry.potionTicks > 0) { + PotionEffectType.INVISIBILITY.createEffect(entry.potionTicks, 1).apply(player); + } + } + } + } + + final GhostSquadronMatchModule matchModule; + + public static class RevealEntry { + public int revealTicks; + public int potionTicks; + + public RevealEntry() { + this(0, 0); + } + + public RevealEntry(int revealTicks, int potionTicks) { + this.revealTicks = revealTicks; + this.potionTicks = potionTicks; + } + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/goals/Contribution.java b/PGM/src/main/java/tc/oc/pgm/goals/Contribution.java new file mode 100644 index 0000000..e200831 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/goals/Contribution.java @@ -0,0 +1,23 @@ +package tc.oc.pgm.goals; + +import com.google.common.base.Preconditions; +import tc.oc.pgm.match.MatchPlayerState; + +public class Contribution { + private final MatchPlayerState player; + private final double percentage; + + public Contribution(MatchPlayerState player, double percentage) { + Preconditions.checkArgument(percentage > 0 && percentage <= 1, "percentage must be greater than zero and less than or equal to 1"); + this.player = player; + this.percentage = percentage; + } + + public MatchPlayerState getPlayerState() { + return player; + } + + public double getPercentage() { + return percentage; + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/goals/Goal.java b/PGM/src/main/java/tc/oc/pgm/goals/Goal.java new file mode 100644 index 0000000..8866a6e --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/goals/Goal.java @@ -0,0 +1,97 @@ +package tc.oc.pgm.goals; + +import java.util.Optional; +import java.util.stream.Stream; +import javax.annotation.Nullable; + +import net.md_5.bungee.api.ChatColor; +import net.md_5.bungee.api.chat.BaseComponent; +import org.bukkit.Color; +import org.bukkit.DyeColor; +import tc.oc.api.docs.virtual.MatchDoc; +import tc.oc.pgm.bossbar.BossBarSource; +import tc.oc.pgm.features.SluggedFeature; +import tc.oc.pgm.match.Competitor; +import tc.oc.pgm.match.Match; +import tc.oc.pgm.match.Party; + +/** + * TODO: Extract CompletableGoal which flags and CPs don't implement + */ +public interface Goal extends SluggedFeature { + + /** + * @return the {@link Match} this goal is part of + */ + Match getMatch(); + + /** + * Test if the given team is allowed to complete this goal. + */ + boolean canComplete(Competitor team); + + default Stream completers() { + return getMatch().getCompetitors().stream().filter(this::canComplete); + } + + /** + * Test if this goal is completed by any team + */ + boolean isCompleted(); + + /** + * Test if this goal is completed for the given team. If the goal's completion state is not + * team-specific (e.g. a destroyable) then this will return true for any team that is allowed to + * complete the goal, if it is complete. + */ + boolean isCompleted(Competitor team); + + default boolean isCompleted(Optional competitor) { + return competitor.isPresent() ? isCompleted(competitor.get()) + : isCompleted(); + } + + /** + * Returns true if this goal can be completed by multiple teams (e.g. a capture point). + * + * Currently, this affects how the goal is displayed on the scoreboard. + */ + default boolean isShared() { + return getDefinition().isShared(); + } + + /** + * Returns true if the goal acts "normally". Normal behavior is defined when the goal is visible + * via mediums such as the {@link BossBarSource}, the Scoreboard, and chat. If a call + * to this method returns false, this goal will not show up anywhere. + * + * In most cases, this should simply delegate to {@link GoalDefinition#isVisible()} + */ + boolean isVisible(); + + boolean isRequired(); + + /** + * The name of the goal, as displayed to players on scoreboards and such. Usually delegates to + * {@link GoalDefinition#getName()} but it's possible to implement a goal that can be renamed. + */ + String getName(); + String getColoredName(); + BaseComponent getComponentName(); + + /** + * A color used for fireworks displays + */ + Color getColor(); + DyeColor getDyeColor(); + + ChatColor renderSidebarStatusColor(@Nullable Competitor competitor, Party viewer); + + String renderSidebarStatusText(@Nullable Competitor competitor, Party viewer); + + ChatColor renderSidebarLabelColor(@Nullable Competitor competitor, Party viewer); + + String renderSidebarLabelText(@Nullable Competitor competitor, Party viewer); + + MatchDoc.Goal getDocument(); +} diff --git a/PGM/src/main/java/tc/oc/pgm/goals/GoalCommands.java b/PGM/src/main/java/tc/oc/pgm/goals/GoalCommands.java new file mode 100644 index 0000000..78809ff --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/goals/GoalCommands.java @@ -0,0 +1,85 @@ +package tc.oc.pgm.goals; + +import com.sk89q.minecraft.util.commands.Command; +import com.sk89q.minecraft.util.commands.CommandContext; +import com.sk89q.minecraft.util.commands.CommandException; +import com.sk89q.minecraft.util.commands.CommandPermissions; +import org.bukkit.ChatColor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import tc.oc.pgm.match.Match; +import tc.oc.pgm.match.MatchPlayer; +import tc.oc.pgm.commands.CommandUtils; +import tc.oc.pgm.teams.Team; +import tc.oc.pgm.teams.TeamMatchModule; + +import java.util.ArrayList; +import java.util.List; + +import static tc.oc.commons.core.util.Nullables.castOrNull; + +public class GoalCommands { + private GoalCommands() {} + + @Command( + aliases = {"proximity"}, + desc = "Show stats about how close each competitor has been to each objective", + min = 0, + max = 0 + ) + @CommandPermissions("pgm.proximity") + public static void proximity(CommandContext args, CommandSender sender) throws CommandException { + Match match = CommandUtils.getMatch(sender); + TeamMatchModule tmm = CommandUtils.getMatchModule(TeamMatchModule.class, sender); + + MatchPlayer matchPlayer = sender instanceof Player ? match.getPlayer((Player) sender) : null; + if(matchPlayer != null && matchPlayer.isParticipating()) { + throw new CommandException("The /proximity command is only available to observers"); + } + + List lines = new ArrayList<>(); + for(Team team : tmm.getTeams()) { + boolean teamHeader = false; + final GoalMatchModule gmm = match.needMatchModule(GoalMatchModule.class); + + for(Goal goal : gmm.getGoals(team)) { + if(goal instanceof TouchableGoal && goal.isVisible()) { + TouchableGoal touchable = (TouchableGoal) goal; + ProximityGoal proximity = castOrNull(goal, ProximityGoal.class); + + if(!teamHeader) { + lines.add(team.getColoredName()); + teamHeader = true; + } + + String line = ChatColor.WHITE + " " + touchable.getColoredName() + ChatColor.WHITE; + + if(touchable.isCompleted(team)) { + line += ChatColor.GREEN + " COMPLETE"; + } else if(touchable.hasTouched(team)) { + line += ChatColor.YELLOW + " TOUCHED"; + } else { + line += ChatColor.RED + " UNTOUCHED"; + } + + if(proximity != null && proximity.isProximityRelevant(team)) { + ProximityMetric metric = proximity.getProximityMetric(team); + if(metric != null) { + line += ChatColor.GRAY + " " + metric.description() + ": " + + ChatColor.AQUA + String.format("%.2f", proximity.getMinimumDistance(team)); + } + } + + lines.add(line); + } + } + } + + if(lines.isEmpty()) { + sender.sendMessage(ChatColor.RED + "There are no objectives that track proximity"); + } else { + sender.sendMessage(lines.toArray(new String[lines.size()])); + } + } + +} diff --git a/PGM/src/main/java/tc/oc/pgm/goals/GoalComponent.java b/PGM/src/main/java/tc/oc/pgm/goals/GoalComponent.java new file mode 100644 index 0000000..61f2626 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/goals/GoalComponent.java @@ -0,0 +1,103 @@ +package tc.oc.pgm.goals; + +import java.util.Objects; +import javax.annotation.Nullable; + +import net.md_5.bungee.api.chat.BaseComponent; +import org.bukkit.command.CommandSender; +import tc.oc.commons.bukkit.chat.ComponentRenderContext; +import tc.oc.commons.bukkit.chat.RenderableComponent; +import tc.oc.commons.core.chat.Component; +import tc.oc.commons.core.chat.ImmutableComponent; +import tc.oc.commons.core.util.Utils; +import tc.oc.pgm.match.Competitor; +import tc.oc.pgm.match.Match; +import tc.oc.pgm.match.MatchPlayer; +import tc.oc.pgm.match.Party; + +import static com.google.common.base.Preconditions.checkNotNull; + +/** + * Displays the name of a {@link Goal}, with optional status icon, as they appear + * in the sidebar. + */ +public class GoalComponent extends ImmutableComponent implements RenderableComponent { + + private final Goal goal; + private final @Nullable Competitor competitor; + private final boolean showStatus; + + public GoalComponent(Goal goal, @Nullable Competitor competitor, boolean showStatus) { + this.goal = goal; + this.competitor = competitor; + this.showStatus = showStatus; + } + + /** + * Display the status of a goal with respect to a particular {@link Competitor}. + * + * The goal will appear is it does on the sidebar when grouped under that competitor. + */ + public static GoalComponent forCompetitor(Goal goal, Competitor competitor, boolean showStatus) { + return new GoalComponent(goal, checkNotNull(competitor), showStatus); + } + + /** + * Display the status of a goal in a generic way. + * + * The goal will appear as it does on the top of the sidebar, when not grouped under any competitor. + */ + public static GoalComponent forEveryone(Goal goal, boolean showStatus) { + return new GoalComponent(goal, null, showStatus); + } + + public Goal goal() { + return goal; + } + + @Nullable + public Competitor competitor() { + return competitor; + } + + public boolean showStatus() { + return showStatus; + } + + @Override + public GoalComponent duplicate() { + return new GoalComponent(goal, competitor, showStatus); + } + + @Override + public BaseComponent render(ComponentRenderContext context, CommandSender viewer) { + final Match match = goal.getMatch(); + final MatchPlayer player = match.getPlayer(viewer); + final Party party = player != null ? player.getParty() + : match.getDefaultParty(); + + final Component c = new Component(goal.renderSidebarLabelColor(competitor, party)); + if(showStatus) { + c.extra(new Component(goal.renderSidebarStatusText(competitor, party), + goal.renderSidebarStatusColor(competitor, party))) + .extra(" "); + } + c.extra(goal.renderSidebarLabelText(competitor, party)); + return c; + } + + @Override + protected boolean equals(BaseComponent obj) { + return Utils.equals(GoalComponent.class, this, obj, that -> + goal.equals(that.goal()) && + Objects.equals(competitor, that.competitor()) && + showStatus == that.showStatus() && + super.equals(that) + ); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), goal, competitor, showStatus); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/goals/GoalDefinition.java b/PGM/src/main/java/tc/oc/pgm/goals/GoalDefinition.java new file mode 100644 index 0000000..d54fbfe --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/goals/GoalDefinition.java @@ -0,0 +1,35 @@ +package tc.oc.pgm.goals; + +import javax.annotation.Nullable; + +import net.md_5.bungee.api.chat.BaseComponent; +import tc.oc.pgm.features.FeatureFactory; +import tc.oc.pgm.features.FeatureInfo; +import tc.oc.pgm.features.SluggedFeatureDefinition; +import tc.oc.pgm.match.Match; + +/** + * Definition of a goal/objective feature. Provides a name field, used to identify + * the goal to players, and to generate a default ID. There is also a visibility + * flag. An invisible goal does not appear in any scoreboards, chat messages, or + * anything else that would directly indicate its existence. + */ +@FeatureInfo(name = "objective") +public interface GoalDefinition> extends SluggedFeatureDefinition, FeatureFactory { + + String getName(); + + String getColoredName(); + + BaseComponent getComponentName(); + + boolean isShared(); + + @Nullable Boolean isRequired(); + + boolean isVisible(); + + default G getGoal(Match match) { + return match.features().get(this); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/goals/GoalDefinitionImpl.java b/PGM/src/main/java/tc/oc/pgm/goals/GoalDefinitionImpl.java new file mode 100644 index 0000000..d722174 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/goals/GoalDefinitionImpl.java @@ -0,0 +1,51 @@ +package tc.oc.pgm.goals; + +import javax.annotation.Nullable; + +import net.md_5.bungee.api.chat.BaseComponent; +import tc.oc.commons.core.chat.Component; +import tc.oc.pgm.features.FeatureDefinition; + +import static com.google.common.base.Preconditions.checkNotNull; + +public abstract class GoalDefinitionImpl> extends FeatureDefinition.Impl implements GoalDefinition { + private final @Inspect @Nullable Boolean required; + private final @Inspect boolean visible; + private final @Inspect String name; + + public GoalDefinitionImpl(String name, @Nullable Boolean required, boolean visible) { + this.name = checkNotNull(name); + this.required = required; + this.visible = visible; + } + + @Override + public String defaultSlug() { + return GoalDefinition.super.defaultSlug() + "-" + slugify(getName()); + } + + @Override + public String getName() { + return this.name; + } + + @Override + public String getColoredName() { + return this.getName(); + } + + @Override + public BaseComponent getComponentName() { + return new Component(getName()); + } + + @Override + public @Nullable Boolean isRequired() { + return this.required; + } + + @Override + public boolean isVisible() { + return this.visible; + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/goals/GoalMatchModule.java b/PGM/src/main/java/tc/oc/pgm/goals/GoalMatchModule.java new file mode 100644 index 0000000..2399aed --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/goals/GoalMatchModule.java @@ -0,0 +1,166 @@ +package tc.oc.pgm.goals; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map.Entry; + +import com.google.common.cache.LoadingCache; +import com.google.common.collect.ArrayListMultimap; +import com.google.common.collect.HashMultimap; +import com.google.common.collect.Multimap; +import com.google.common.reflect.TypeToken; +import org.bukkit.Sound; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import tc.oc.commons.bukkit.chat.BukkitSound; +import tc.oc.commons.core.util.CacheUtils; +import tc.oc.pgm.events.CompetitorAddEvent; +import tc.oc.pgm.events.CompetitorRemoveEvent; +import tc.oc.pgm.events.ListenerScope; +import tc.oc.pgm.goals.events.GoalCompleteEvent; +import tc.oc.pgm.goals.events.GoalProximityChangeEvent; +import tc.oc.pgm.goals.events.GoalStatusChangeEvent; +import tc.oc.pgm.goals.events.GoalTouchEvent; +import tc.oc.pgm.match.Competitor; +import tc.oc.pgm.match.MatchModule; +import tc.oc.pgm.match.MatchPlayer; +import tc.oc.pgm.match.MatchScope; +import tc.oc.pgm.victory.VictoryMatchModule; + +@ListenerScope(MatchScope.LOADED) +public class GoalMatchModule extends MatchModule implements Listener { + + protected static final BukkitSound GOOD_SOUND = new BukkitSound(Sound.BLOCK_PORTAL_TRAVEL, 0.7f, 2f); + protected static final BukkitSound BAD_SOUND = new BukkitSound(Sound.ENTITY_BLAZE_DEATH, 0.8f, 0.8f); + + protected final List goals = new ArrayList<>(); + protected final Multimap goalsByCompetitor = ArrayListMultimap.create(); + protected final Multimap competitorsByGoal = HashMultimap.create(); + protected final LoadingCache progressCache = CacheUtils.newCache(GoalProgress::new); + + @Override + public void load() { + super.load(); + match.featureDefinitions() + .all(new TypeToken>(){}) + .map(match.features()::get) + .forEach(this::addGoal); + } + + private void addGoal(Goal goal) { + logger.fine("Adding goal " + goal); + + if(!goal.isVisible()) return; + + if(goals.isEmpty()) { + logger.fine("First goal added, appending " + GoalsVictoryCondition.class.getSimpleName()); + match.needMatchModule(VictoryMatchModule.class).setVictoryCondition(new GoalsVictoryCondition(goalsByCompetitor)); + } + + goals.add(goal); + + for(Competitor competitor : match.getCompetitors()) { + addCompetitorGoal(competitor, goal); + } + } + + public Collection getGoals() { + return goals; + } + + public Collection getGoals(Competitor competitor) { + return goalsByCompetitor.get(competitor); + } + + public Collection getCompetitors(Goal goal) { + return competitorsByGoal.get(goal); + } + + public Multimap getGoalsByCompetitor() { + return goalsByCompetitor; + } + + public Multimap getCompetitorsByGoal() { + return competitorsByGoal; + } + + private void addCompetitorGoal(Competitor competitor, Goal goal) { + if(goal.canComplete(competitor)) { + logger.fine("Competitor " + competitor + " can complete goal " + goal); + + goalsByCompetitor.put(competitor, goal); + competitorsByGoal.put(goal, competitor); + } + } + + @EventHandler + public void onCompetitorAdd(CompetitorAddEvent event) { + logger.fine("Competitor added " + event.getCompetitor()); + + for(Goal goal : goals) { + addCompetitorGoal(event.getCompetitor(), goal); + } + } + + @EventHandler + public void onCompetitorRemove(CompetitorRemoveEvent event) { + progressCache.invalidate(event.getCompetitor()); + goalsByCompetitor.removeAll(event.getCompetitor()); + competitorsByGoal.entries().removeIf(entry -> entry.getValue().equals(event.getCompetitor())); + } + + @SuppressWarnings("unchecked") + public Multimap getGoals(Class filterClass) { + Multimap filteredGoals = ArrayListMultimap.create(); + for(Entry entry : this.goalsByCompetitor.entries()) { + if(filterClass.isInstance(entry.getValue())) { + filteredGoals.put(entry.getKey(), (T) entry.getValue()); + } + } + return filteredGoals; + } + + public int compareProgress(Competitor a, Competitor b) { + return progressCache.getUnchecked(a).compareTo(progressCache.getUnchecked(b)); + } + + protected void updateProgress(Goal goal) { + competitorsByGoal.get(goal).forEach(progressCache::invalidate); + match.needMatchModule(VictoryMatchModule.class).invalidateCompetitorRanking(); + } + + // TODO: These events will often be fired together.. debounce them somehow? + + @EventHandler + public void onComplete(GoalCompleteEvent event) { + updateProgress(event.getGoal()); + + // Don't play the objective sound if the match is over, because the win/lose sound will play instead + if(!match.needMatchModule(VictoryMatchModule.class).checkMatchEnd() && event.getGoal().isVisible()) { + for(MatchPlayer player : event.getMatch().getPlayers()) { + if(!(player.getParty() instanceof Competitor)) { + player.playSound(GOOD_SOUND); + } else { + final Competitor competitor = (Competitor) player.getParty(); + player.playSound(event.wasCompletedFor(competitor) && !event.isCompletedFor(competitor) ? BAD_SOUND : GOOD_SOUND); + } + } + } + } + + @EventHandler + public void onStatusChange(GoalStatusChangeEvent event) { + updateProgress(event.getGoal()); + } + + @EventHandler + public void onProximityChange(GoalProximityChangeEvent event) { + updateProgress(event.getGoal()); + } + + @EventHandler + public void onTouch(GoalTouchEvent event) { + updateProgress(event.getGoal()); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/goals/GoalModule.java b/PGM/src/main/java/tc/oc/pgm/goals/GoalModule.java new file mode 100644 index 0000000..a783ae5 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/goals/GoalModule.java @@ -0,0 +1,31 @@ +package tc.oc.pgm.goals; + +import java.util.logging.Logger; +import javax.annotation.Nullable; + +import net.md_5.bungee.api.chat.BaseComponent; +import net.md_5.bungee.api.chat.TranslatableComponent; +import org.jdom2.Document; +import tc.oc.pgm.map.MapModule; +import tc.oc.pgm.map.MapModuleContext; +import tc.oc.pgm.map.MapModuleFactory; +import tc.oc.pgm.module.ModuleDescription; +import tc.oc.pgm.xml.InvalidXMLException; + +@ModuleDescription(name = "goals") +public class GoalModule implements MapModule { + + private static final BaseComponent GAME = new TranslatableComponent("match.scoreboard.objectives.title"); + + @Override + public @Nullable BaseComponent getGameName(MapModuleContext context) { + return context.features().containsAny(GoalDefinition.class) ? GAME : null; + } + + public static class Factory extends MapModuleFactory { + @Override + public GoalModule parse(MapModuleContext context, Logger logger, Document doc) throws InvalidXMLException { + return new GoalModule(); + } + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/goals/GoalProgress.java b/PGM/src/main/java/tc/oc/pgm/goals/GoalProgress.java new file mode 100644 index 0000000..87152fc --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/goals/GoalProgress.java @@ -0,0 +1,162 @@ +package tc.oc.pgm.goals; + +import com.google.common.collect.ImmutableList; +import tc.oc.pgm.match.Competitor; +import tc.oc.pgm.match.Match; + +import javax.annotation.Nonnull; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +/** + * A measurement of progress towards completing goals in a match + */ +public class GoalProgress implements Comparable { + public final int completed; // Number of completed goals + public final int touched; // Number of completed goals + public final ImmutableList progress; // Progress of incremental goals, from highest to lowest + public final ImmutableList completionProximity; // Distance squared to untouched goals, from lowest to highest + public final ImmutableList touchProximity; // Distance squared to touched goals, from lowest to highest + + private static > T shorter(T a, T b) { + return a.size() < b.size() ? a : b; + } + + private static int compareProgresses(List a, List b) { + int count = Math.max(a.size(), b.size()); + double aProgress = 0, bProgress = 0; + + for(int i = 0; i < count; i++) { + aProgress = i < a.size() ? a.get(i) : 0; + bProgress = i < b.size() ? b.get(i) : 0; + if(aProgress != bProgress) break; // Find first differing progress + } + + return Double.compare(bProgress, aProgress); + } + + /** + * Only compare as many proximity scores as both teams have available. + * If one team has more proximity scores available than the other, + * ignore the extras. + */ + private static int compareProximities(List a, List b) { + int count = Math.min(a.size(), b.size()); + int aProximity = Integer.MAX_VALUE, bProximity = Integer.MAX_VALUE; + + for(int i = 0; i < count; i++) { + aProximity = a.get(i); + bProximity = b.get(i); + if(aProximity != bProximity) break; + } + + return Integer.compare(aProximity, bProximity); + } + + private GoalProgress(int completed, int touched, Collection progress, Collection completionProximity, Collection touchProximity) { + this.completed = completed; + this.touched = touched; + this.progress = ImmutableList.copyOf(progress); + this.completionProximity = ImmutableList.copyOf(completionProximity); + this.touchProximity = ImmutableList.copyOf(touchProximity); + } + + GoalProgress(Competitor competitor) { + Match match = competitor.getMatch(); + + int completed = 0; + int touched = 0; + List progress = new ArrayList<>(); + List completionProximity = new ArrayList<>(); + List touchProximity = new ArrayList<>(); + final GoalMatchModule gmm = match.needMatchModule(GoalMatchModule.class); + + for(Goal goal : gmm.getGoals(competitor)) { + if(goal.isRequired()) { + if(goal.isCompleted(competitor)) { + completed++; + } else { + if(goal instanceof ProximityGoal) { + ProximityGoal proximity = (ProximityGoal) goal; + TouchableGoal touchable = goal instanceof TouchableGoal ? (TouchableGoal) goal : null; + + if(touchable != null && touchable.hasTouched(competitor)) { + touched++; + if(proximity.isProximityRelevant(competitor)) { + completionProximity.add(proximity.getProximity(competitor)); + } + } else { + if(proximity.isProximityRelevant(competitor)) { + touchProximity.add(proximity.getProximity(competitor)); + } + } + + if(goal instanceof IncrementalGoal) { + IncrementalGoal incrementalGoal = (IncrementalGoal) goal; + progress.add(incrementalGoal.getCompletion()); + } else if(touchable != null && touchable.hasTouched(competitor)) { + // A touched, non-incremental goal is worth 50% completion + progress.add(0.5); + } + } + } + } + } + + Collections.sort(progress, Collections.reverseOrder()); + Collections.sort(completionProximity); + Collections.sort(touchProximity); + + this.completed = completed; + this.touched = touched; + this.progress = ImmutableList.copyOf(progress); + this.completionProximity = ImmutableList.copyOf(completionProximity); + this.touchProximity = ImmutableList.copyOf(touchProximity); + } + + @Override + public int compareTo(@Nonnull GoalProgress that) { + // This team has more completed goals, so they take the lead + if(this.completed > that.completed) return -1; + if(this.completed < that.completed) return 1; + + // Equal number of completed goals, compare touches + if(this.touched > that.touched) return -1; + if(this.touched < that.touched) return 1; + + // Equal number of touches, compare the progress of incremental goals from highest to lowest + int compareProgress = compareProgresses(this.progress, that.progress); + if(compareProgress != 0) return compareProgress; + + // All progresses are equal, compare proximity to a completion + int compareCompletionProximity = compareProximities(this.completionProximity, that.completionProximity); + if(compareCompletionProximity != 0) return compareCompletionProximity; + + // This team got equally close to another completion, compare proximity to touch + int compareTouchProximity = compareProximities(this.touchProximity, that.touchProximity); + if(compareTouchProximity != 0) return compareTouchProximity; + + // Both teams are equal in every measurable respect + return 0; + } + + @Override + public boolean equals(Object obj) { + return obj instanceof GoalProgress && this.compareTo((GoalProgress) obj) == 0; + } + + /** + * Given two tied sets of accomplishments, return the common subset of + * accomplishments that can be used to compare the two sets. + */ + public static GoalProgress commonSubset(GoalProgress a, GoalProgress b) { + return new GoalProgress(a.touched, + a.completed, + a.progress, + shorter(a.completionProximity, b.completionProximity), + shorter(a.touchProximity, b.touchProximity)); + + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/goals/GoalsMatchResult.java b/PGM/src/main/java/tc/oc/pgm/goals/GoalsMatchResult.java new file mode 100644 index 0000000..6d75ca5 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/goals/GoalsMatchResult.java @@ -0,0 +1,20 @@ +package tc.oc.pgm.goals; + +import net.md_5.bungee.api.chat.BaseComponent; +import tc.oc.commons.core.chat.Component; +import tc.oc.pgm.match.Competitor; +import tc.oc.pgm.victory.MatchResult; + +public class GoalsMatchResult implements MatchResult { + @Override + public int compare(Competitor a, Competitor b) { + return a.getMatch() + .needMatchModule(GoalMatchModule.class) + .compareProgress(a, b); + } + + @Override + public BaseComponent describeResult() { + return new Component("most objectives"); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/goals/GoalsVictoryCondition.java b/PGM/src/main/java/tc/oc/pgm/goals/GoalsVictoryCondition.java new file mode 100644 index 0000000..d8b793b --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/goals/GoalsVictoryCondition.java @@ -0,0 +1,38 @@ +package tc.oc.pgm.goals; + +import java.util.Collection; +import java.util.Map; + +import com.google.common.collect.Multimap; +import tc.oc.pgm.match.Competitor; +import tc.oc.pgm.match.Match; +import tc.oc.pgm.victory.AbstractVictoryCondition; + +// TODO: Break this down into multiple chainable conditions i.e. completions, touches, proximity, etc. +public class GoalsVictoryCondition extends AbstractVictoryCondition { + + private final Multimap goalsByCompetitor; + + public GoalsVictoryCondition(Multimap goalsByCompetitor) { + super(Priority.GOALS, new GoalsMatchResult()); + this.goalsByCompetitor = goalsByCompetitor; + } + + @Override + public boolean isCompleted() { + competitors: for(Map.Entry> entry : goalsByCompetitor.asMap().entrySet()) { + boolean someRequired = false; + for(Goal goal : entry.getValue()) { + if(goal.isRequired()) { + // If any required goals are incomplete, skip to the next competitor + if(!goal.isCompleted(entry.getKey())) continue competitors; + someRequired = true; + } + } + // If some goals are required, and they are all complete, competitor wins the match + if(someRequired) return true; + } + // If no competitors won, match is not over + return false; + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/goals/IncrementalGoal.java b/PGM/src/main/java/tc/oc/pgm/goals/IncrementalGoal.java new file mode 100644 index 0000000..3c2f13c --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/goals/IncrementalGoal.java @@ -0,0 +1,33 @@ +package tc.oc.pgm.goals; + +import javax.annotation.Nullable; + +import tc.oc.api.docs.virtual.MatchDoc; + +/** + * A {@link Goal} that is completed gradually, and can report completion as a percentage + */ +public interface IncrementalGoal extends Goal { + /** + * Return the completion percentage of this goal in the range 0..1 + */ + double getCompletion(); + + /** + * Render a string representation of goal completion + */ + String renderCompletion(); + + /** + * Render a precise representation of goal completion, or null if no precise format is supported + */ + @Nullable String renderPreciseCompletion(); + + /** + * True if partial completion should be visible to participating players + */ + boolean getShowProgress(); + + @Override + MatchDoc.IncrementalGoal getDocument(); +} diff --git a/PGM/src/main/java/tc/oc/pgm/goals/ModeChangeGoal.java b/PGM/src/main/java/tc/oc/pgm/goals/ModeChangeGoal.java new file mode 100644 index 0000000..d81d571 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/goals/ModeChangeGoal.java @@ -0,0 +1,16 @@ +package tc.oc.pgm.goals; + +import org.bukkit.block.Block; +import org.bukkit.material.MaterialData; + +public interface ModeChangeGoal extends Goal { + + void replaceBlocks(MaterialData newMaterial); + + boolean isObjectiveMaterial(Block block); + + String getModeChangeMessage(); + + boolean isAffectedByModeChanges(); + +} diff --git a/PGM/src/main/java/tc/oc/pgm/goals/OwnableGoalDefinition.java b/PGM/src/main/java/tc/oc/pgm/goals/OwnableGoalDefinition.java new file mode 100644 index 0000000..87de8b7 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/goals/OwnableGoalDefinition.java @@ -0,0 +1,21 @@ +package tc.oc.pgm.goals; + +import java.util.Optional; +import javax.annotation.Nullable; + +import tc.oc.pgm.teams.TeamFactory; + +/** + * Definition of a goal that may be "owned" by a particular team. The ramifications of + * ownership depend entirely on the type of goal. Some goals are pursued by their + * owner, some are defended by their owner. The only thing the base class does with + * the owner is store it and use it as part of the default ID. + */ +public interface OwnableGoalDefinition> extends GoalDefinition { + + Optional owner(); + + default @Nullable TeamFactory getOwner() { + return owner().orElse(null); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/goals/OwnableGoalDefinitionImpl.java b/PGM/src/main/java/tc/oc/pgm/goals/OwnableGoalDefinitionImpl.java new file mode 100644 index 0000000..4314d49 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/goals/OwnableGoalDefinitionImpl.java @@ -0,0 +1,32 @@ +package tc.oc.pgm.goals; + +import java.util.Optional; + +import tc.oc.pgm.teams.TeamFactory; + +import javax.annotation.Nullable; + +import static com.google.common.base.Preconditions.checkNotNull; + +public abstract class OwnableGoalDefinitionImpl> extends GoalDefinitionImpl implements OwnableGoalDefinition { + + @Inspect(brief=true) + private final Optional owner; + + public OwnableGoalDefinitionImpl(String name, @Nullable Boolean required, boolean visible, Optional owner) { + super(name, required, visible); + this.owner = checkNotNull(owner); + } + + @Override + public String defaultSlug() { + final String slug = super.defaultSlug(); + return owner().map(team -> slug + "-" + team.defaultSlug()) + .orElse(slug); + } + + @Override + public Optional owner() { + return owner; + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/goals/OwnedGoal.java b/PGM/src/main/java/tc/oc/pgm/goals/OwnedGoal.java new file mode 100644 index 0000000..7964575 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/goals/OwnedGoal.java @@ -0,0 +1,48 @@ +package tc.oc.pgm.goals; + +import javax.annotation.Nullable; + +import org.bukkit.DyeColor; +import tc.oc.api.docs.virtual.MatchDoc; +import tc.oc.commons.bukkit.util.BukkitUtils; +import tc.oc.pgm.match.Match; +import tc.oc.pgm.teams.Team; +import tc.oc.pgm.teams.TeamMatchModule; + +/** + * A goal with an owning team. Match-time companion to {@link OwnedGoal} + */ +public abstract class OwnedGoal extends SimpleGoal { + + protected final Team owner; + + public OwnedGoal(T definition, Match match) { + super(definition, match); + this.owner = definition.getOwner() == null ? null : match.needMatchModule(TeamMatchModule.class).team(definition.getOwner()); + } + + public @Nullable Team getOwner() { + return this.owner; + } + + @Override + public DyeColor getDyeColor() { + return owner != null ? BukkitUtils.chatColorToDyeColor(owner.getColor()) + : DyeColor.WHITE; + } + + @Override + public abstract MatchDoc.OwnedGoal getDocument(); + + class Document extends SimpleGoal.Document implements MatchDoc.OwnedGoal { + @Override + public @Nullable String owner_id() { + return getOwner() == null ? null : getOwner().slug(); + } + + @Override + public @Nullable String owner_name() { + return getOwner() == null ? null : getOwner().getName(); + } + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/goals/ProximityGoal.java b/PGM/src/main/java/tc/oc/pgm/goals/ProximityGoal.java new file mode 100644 index 0000000..1949a0c --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/goals/ProximityGoal.java @@ -0,0 +1,224 @@ +package tc.oc.pgm.goals; + +import java.util.Map; +import javax.annotation.Nullable; + +import com.google.common.collect.ImmutableSet; +import net.md_5.bungee.api.ChatColor; +import org.bukkit.Location; +import org.bukkit.block.BlockState; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import tc.oc.commons.bukkit.util.BlockUtils; +import tc.oc.commons.core.chat.ChatUtils; +import tc.oc.commons.core.localization.Locales; +import tc.oc.commons.core.util.DefaultMapAdapter; +import tc.oc.pgm.events.ListenerScope; +import tc.oc.pgm.match.Competitor; +import tc.oc.pgm.Config; +import tc.oc.pgm.match.Match; +import tc.oc.pgm.match.MatchPlayer; +import tc.oc.pgm.match.MatchScope; +import tc.oc.pgm.match.ParticipantState; +import tc.oc.pgm.match.Party; +import tc.oc.commons.bukkit.event.CoarsePlayerMoveEvent; +import tc.oc.pgm.events.CompetitorRemoveEvent; +import tc.oc.pgm.events.MatchPlayerDeathEvent; +import tc.oc.pgm.events.ParticipantBlockTransformEvent; +import tc.oc.pgm.goals.events.GoalCompleteEvent; +import tc.oc.pgm.goals.events.GoalProximityChangeEvent; +import tc.oc.pgm.goals.events.GoalTouchEvent; + +@ListenerScope(MatchScope.RUNNING) +public abstract class ProximityGoal extends OwnedGoal implements Listener { + + private final Map proximity = new DefaultMapAdapter<>(Integer.MAX_VALUE); + + public ProximityGoal(T definition, Match match) { + super(definition, match); + match.registerEvents(this); + } + + /** + * Get the locations from which proximity can be measured relative to. + * The shortest measurement will be used. + */ + public abstract Iterable getProximityLocations(ParticipantState player); + + public @Nullable ProximityMetric getProximityMetric(Competitor team) { + return getDefinition().getPreTouchMetric(); + } + + public @Nullable ProximityMetric.Type getProximityMetricType(Competitor team) { + ProximityMetric metric = getProximityMetric(team); + return metric == null ? null : metric.type; + } + + /** + * Is proximity relevant at the present moment for the given team? + * That is, can it be measured and affect the outcome of te match? + */ + public boolean isProximityRelevant(Competitor team) { + return canComplete(team) && !isCompleted() && getProximityMetric(team) != null; + } + + protected boolean canPlayerUpdateProximity(ParticipantState player) { + return canComplete(player.getParty()); + } + + protected boolean canBlockUpdateProximity(BlockState oldState, BlockState newState) { + return true; + } + + private static double distanceFromDistanceSquared(int squared) { + return squared == Integer.MAX_VALUE ? Double.POSITIVE_INFINITY : Math.sqrt(squared); + } + + public int getProximity(Competitor team) { + return this.proximity.get(team); + } + + /** + * Get the minimum distance the given team has been from the objective at + * any time during the match (which is +Inf at the start of the match). + * The given metric determines exactly how this is measured. + */ + public double getMinimumDistance(Competitor team) { + return distanceFromDistanceSquared(this.getProximity(team)); + } + + public void resetProximity(Competitor team) { + Integer oldProximity = proximity.remove(team); + if(oldProximity != null) { + getMatch().callEvent(new GoalProximityChangeEvent(this, team, null, distanceFromDistanceSquared(oldProximity), Double.POSITIVE_INFINITY)); + } + } + + public void resetProximity() { + for(Competitor team : ImmutableSet.copyOf(proximity.keySet())) { + resetProximity(team); + } + } + + public int getProximityFrom(ParticipantState player, Location location) { + if(Double.isInfinite(location.lengthSquared())) return Integer.MAX_VALUE; + + ProximityMetric metric = getProximityMetric(player.getParty()); + if(metric == null) return Integer.MAX_VALUE; + + int minimumDistance = Integer.MAX_VALUE; + for(Location v : getProximityLocations(player)) { + // If either point is at infinity, the distance is infinite + if(Double.isInfinite(v.lengthSquared())) continue; + + int dx = location.getBlockX() - v.getBlockX(); + int dy = location.getBlockY() - v.getBlockY(); + int dz = location.getBlockZ() - v.getBlockZ(); + + // Note: distances stay squared as long as possible + int distance; + if(metric.horizontal) { + distance = dx*dx + dz*dz; + } else { + distance = dx*dx + dy*dy + dz*dz; + } + + if(distance < minimumDistance) { + minimumDistance = distance; + } + } + + return minimumDistance; + } + + public boolean updateProximity(ParticipantState player, Location location) { + if(isProximityRelevant(player.getParty()) && canPlayerUpdateProximity(player)) { + int oldProximity = proximity.get(player.getParty()); + int newProximity = getProximityFrom(player, location); + if(newProximity < oldProximity) { + proximity.put(player.getParty(), newProximity); + getMatch().callEvent( + new GoalProximityChangeEvent(this, player.getParty(), location, + distanceFromDistanceSquared(oldProximity), + distanceFromDistanceSquared(newProximity)) + ); + return true; + } + } + return false; + } + + public boolean shouldShowProximity(@Nullable Competitor team, Party viewer) { + return team != null && + Config.Scoreboard.showProximity() && + isProximityRelevant(team) && + (viewer == team || viewer.isObservingType()); + } + + public ChatColor renderProximityColor(Competitor team, Party viewer) { + return ChatColor.GRAY; + } + + public String renderProximity(@Nullable Competitor team, Party viewer) { + if(!shouldShowProximity(team, viewer)) return ""; + + String text; + double distance = this.getMinimumDistance(team); + if(distance == Double.POSITIVE_INFINITY) { + text = "\u221e"; // ∞ + } else { + text = ChatUtils.tiny(String.format(Locales.DEFAULT_LOCALE, "%.1f", distance)); + } + + return renderProximityColor(team, viewer) + text; + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + private void onPlayerMove(CoarsePlayerMoveEvent event) { + MatchPlayer player = getMatch().getParticipant(event.getPlayer()); + if(player != null && + getProximityMetricType(player.getCompetitor()) == ProximityMetric.Type.CLOSEST_PLAYER) { + + updateProximity(player.getParticipantState(), event.getBlockTo()); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + private void onPlayerPlaceBlock(ParticipantBlockTransformEvent event) { + if(getProximityMetricType(event.getPlayerState().getParty()) == ProximityMetric.Type.CLOSEST_BLOCK && + canBlockUpdateProximity(event.getOldState(), event.getNewState())) { + + updateProximity(event.getPlayerState(), BlockUtils.center(event.getNewState())); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + private void onPlayerKill(MatchPlayerDeathEvent event) { + if(event.getKiller() != null && + event.isChallengeKill() && + getProximityMetricType(event.getKiller().getParty()) == ProximityMetric.Type.CLOSEST_KILL) { + + updateProximity(event.getKiller(), event.getKiller().getLocation()); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + private void onTouch(GoalTouchEvent event) { + if(this == event.getGoal() && event.isFirstForCompetitor()) { + resetProximity(event.getCompetitor()); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + private void onComplete(GoalCompleteEvent event) { + if(this == event.getGoal()) { + resetProximity(); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + private void onCompetitorRemove(CompetitorRemoveEvent event) { + resetProximity(event.getCompetitor()); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/goals/ProximityGoalDefinition.java b/PGM/src/main/java/tc/oc/pgm/goals/ProximityGoalDefinition.java new file mode 100644 index 0000000..a3d5de7 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/goals/ProximityGoalDefinition.java @@ -0,0 +1,11 @@ +package tc.oc.pgm.goals; + +import javax.annotation.Nullable; + +public interface ProximityGoalDefinition> extends OwnableGoalDefinition { + + ProximityMetric getPreTouchMetric(); + + @Nullable + ProximityMetric getPostTouchMetric(); +} diff --git a/PGM/src/main/java/tc/oc/pgm/goals/ProximityGoalDefinitionImpl.java b/PGM/src/main/java/tc/oc/pgm/goals/ProximityGoalDefinitionImpl.java new file mode 100644 index 0000000..03fdbab --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/goals/ProximityGoalDefinitionImpl.java @@ -0,0 +1,32 @@ +package tc.oc.pgm.goals; + +import java.util.Optional; + +import tc.oc.pgm.teams.TeamFactory; + +import javax.annotation.Nullable; + +public abstract class ProximityGoalDefinitionImpl> extends OwnableGoalDefinitionImpl implements ProximityGoalDefinition { + private final @Inspect ProximityMetric preTouchMetric; + private final @Inspect @Nullable ProximityMetric postTouchMetric; + + public ProximityGoalDefinitionImpl(String name, @Nullable Boolean required, boolean visible, Optional owner, ProximityMetric preTouchMetric, @Nullable ProximityMetric postTouchMetric) { + super(name, required, visible, owner); + this.preTouchMetric = preTouchMetric; + this.postTouchMetric = postTouchMetric; + } + + public ProximityGoalDefinitionImpl(String name, @Nullable Boolean required, boolean visible, Optional owner, ProximityMetric preTouchMetric) { + this(name, required, visible, owner, preTouchMetric, null); + } + + @Override + public ProximityMetric getPreTouchMetric() { + return this.preTouchMetric; + } + + @Override + public @Nullable ProximityMetric getPostTouchMetric() { + return postTouchMetric; + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/goals/ProximityMetric.java b/PGM/src/main/java/tc/oc/pgm/goals/ProximityMetric.java new file mode 100644 index 0000000..d5a98fd --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/goals/ProximityMetric.java @@ -0,0 +1,76 @@ +package tc.oc.pgm.goals; + +import org.jdom2.Element; +import tc.oc.api.docs.virtual.MatchDoc; +import tc.oc.pgm.utils.XMLUtils; +import tc.oc.pgm.xml.InvalidXMLException; +import tc.oc.pgm.xml.Node; + +public class ProximityMetric { + public static enum Type { + CLOSEST_PLAYER("closest player"), + CLOSEST_BLOCK("closest block"), + CLOSEST_KILL("closest kill"); + + public final String description; + + Type(String description) { + this.description = description; + } + } + + public final Type type; + public final boolean horizontal; + + public ProximityMetric(Type type, boolean horizontal) { + this.type = type; + this.horizontal = horizontal; + } + + public String name() { + if(this.horizontal) { + return this.type.name() + "_HORIZONTAL"; + } else { + return this.type.name(); + } + } + + public String description() { + if(this.horizontal) { + return this.type.description + " (horizontal)"; + } else { + return this.type.description; + } + } + + public MatchDoc.TouchableGoal.Proximity.Metric apiValue() { + return MatchDoc.TouchableGoal.Proximity.Metric.valueOf(name()); + } + + @Override + public boolean equals(Object o) { + if(this == o) return true; + if(!(o instanceof ProximityMetric)) return false; + ProximityMetric that = (ProximityMetric) o; + return this.type == that.type && + this.horizontal == that.horizontal; + } + + @Override + public int hashCode() { + int result = type.hashCode(); + result = 31 * result + (horizontal ? 1 : 0); + return result; + } + + public static ProximityMetric parse(Element el, ProximityMetric def) throws InvalidXMLException { + return parse(el, "", def); + } + + public static ProximityMetric parse(Element el, String prefix, ProximityMetric def) throws InvalidXMLException { + if(!prefix.isEmpty()) prefix = prefix + "-"; + + return new ProximityMetric(XMLUtils.parseEnum(Node.fromAttr(el, prefix + "proximity-metric"), ProximityMetric.Type.class, "proximity metric", def.type), + XMLUtils.parseBoolean(el.getAttribute(prefix + "proximity-horizontal"), def.horizontal)); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/goals/SimpleGoal.java b/PGM/src/main/java/tc/oc/pgm/goals/SimpleGoal.java new file mode 100644 index 0000000..3a2aea2 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/goals/SimpleGoal.java @@ -0,0 +1,130 @@ +package tc.oc.pgm.goals; + +import java.util.logging.Logger; +import javax.annotation.Nullable; + +import net.md_5.bungee.api.ChatColor; +import net.md_5.bungee.api.chat.BaseComponent; +import org.bukkit.Color; +import org.bukkit.DyeColor; +import tc.oc.api.docs.AbstractModel; +import tc.oc.api.docs.virtual.MatchDoc; +import tc.oc.commons.core.logging.ClassLogger; +import tc.oc.pgm.match.Competitor; +import tc.oc.pgm.match.Match; +import tc.oc.pgm.match.Party; +import tc.oc.pgm.map.ProtoVersions; +import tc.oc.pgm.score.ScoreMatchModule; + +/** + * Basic {@link Goal} implementation with fields for the definition and match + */ +public abstract class SimpleGoal implements Goal { + + public static final ChatColor COLOR_INCOMPLETE = ChatColor.RED; + public static final ChatColor COLOR_COMPLETE = ChatColor.GREEN; + + public static final String SYMBOL_INCOMPLETE = "\u2715"; // ✕ + public static final String SYMBOL_COMPLETE = "\u2714"; // ✔ + + protected final Logger logger; + protected final T definition; + protected final Match match; + + public SimpleGoal(T definition, Match match) { + this.logger = ClassLogger.get(match.getLogger(), getClass()); + this.definition = definition; + this.match = match; + } + + @Override + public String slug() { + return match.featureDefinitions().slug(getDefinition()); + } + + @Override + public Match getMatch() { + return this.match; + } + + @Override + public T getDefinition() { + return this.definition; + } + + @Override + public String getName() { + return this.definition.getName(); + } + + @Override + public String getColoredName() { + return this.definition.getColoredName(); + } + + @Override + public BaseComponent getComponentName() { + return this.definition.getComponentName(); + } + + @Override + public Color getColor() { + return getDyeColor().getColor(); + } + + @Override + public DyeColor getDyeColor() { + return DyeColor.WHITE; + } + + @Override + public boolean isVisible() { + return this.definition.isVisible(); + } + + @Override + public boolean isRequired() { + Boolean required = getDefinition().isRequired(); + if(required != null) { + return required; + } else if(getMatch().getModuleContext().getProto().isNoOlderThan(ProtoVersions.GOAL_REQUIRED_OPTION)) { + return true; + } else { + // Legacy behavior is to require no goals if score module is loaded + return !getMatch().hasMatchModule(ScoreMatchModule.class); + } + } + + public ChatColor renderSidebarStatusColor(@Nullable Competitor competitor, Party viewer) { + return isCompleted(competitor) ? COLOR_COMPLETE : COLOR_INCOMPLETE; + } + + public String renderSidebarStatusText(@Nullable Competitor competitor, Party viewer) { + return isCompleted(competitor) ? SYMBOL_COMPLETE : SYMBOL_INCOMPLETE; + } + + public ChatColor renderSidebarLabelColor(@Nullable Competitor competitor, Party viewer) { + return ChatColor.WHITE; + } + + public String renderSidebarLabelText(@Nullable Competitor competitor, Party viewer) { + return getName(); + } + + public class Document extends AbstractModel implements MatchDoc.Goal { + @Override + public String _id() { + return slug(); + } + + @Override + public String type() { + return getDefinition().getFeatureName(); + } + + @Override + public String name() { + return getName(); + } + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/goals/TouchableGoal.java b/PGM/src/main/java/tc/oc/pgm/goals/TouchableGoal.java new file mode 100644 index 0000000..b91c3ed --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/goals/TouchableGoal.java @@ -0,0 +1,284 @@ +package tc.oc.pgm.goals; + +import java.util.Collection; +import java.util.HashSet; +import java.util.Iterator; +import java.util.Set; +import javax.annotation.Nullable; + +import com.google.common.base.Function; +import com.google.common.collect.Collections2; +import com.google.common.collect.ImmutableSet; +import net.md_5.bungee.api.ChatColor; +import net.md_5.bungee.api.chat.BaseComponent; +import net.md_5.bungee.api.chat.TranslatableComponent; +import org.bukkit.Bukkit; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import tc.oc.api.docs.virtual.MatchDoc; +import tc.oc.commons.core.chat.Component; +import tc.oc.commons.bukkit.chat.ComponentRenderers; +import tc.oc.pgm.events.ListenerScope; +import tc.oc.pgm.match.Competitor; +import tc.oc.pgm.match.Match; +import tc.oc.pgm.match.MatchPlayer; +import tc.oc.pgm.match.MatchScope; +import tc.oc.pgm.match.ParticipantState; +import tc.oc.pgm.match.Party; +import tc.oc.pgm.events.CompetitorRemoveEvent; +import tc.oc.pgm.goals.events.GoalCompleteEvent; +import tc.oc.pgm.goals.events.GoalTouchEvent; +import tc.oc.pgm.spawns.events.ParticipantDespawnEvent; + +/** + * A {@link Goal} that may be 'touched' by players, meaning the player has + * made some tangible progress in completing the goal. + */ +@ListenerScope(MatchScope.RUNNING) +public abstract class TouchableGoal extends ProximityGoal implements Listener { + + public static final ChatColor COLOR_TOUCHED = ChatColor.YELLOW; + public static final String SYMBOL_TOUCHED = "\u2733"; // ✳ + + protected boolean touched; + protected final Set touchingCompetitors = new HashSet<>(); + protected final Set touchingPlayers = new HashSet<>(); + protected final Set recentTouchingPlayers = new HashSet<>(); + + public TouchableGoal(T definition, Match match) { + super(definition, match); + match.registerEvents(this); + } + + /** + * Should touches NOT be credited until the goal is completed? + */ + public boolean getDeferTouches() { + return false; + } + + /** + * Gets a formatted message designed to be broadcast when a player touches the goal. + * + * @param toucher The player + * @param self is the message for the toucher? + */ + public abstract BaseComponent getTouchMessage(@Nullable ParticipantState toucher, boolean self); + + @Override + public net.md_5.bungee.api.ChatColor renderProximityColor(Competitor team, Party viewer) { + return hasTouched(team) ? net.md_5.bungee.api.ChatColor.YELLOW : super.renderProximityColor(team, viewer); + } + + @Override + public ChatColor renderSidebarStatusColor(@Nullable Competitor competitor, Party viewer) { + return shouldShowTouched(competitor, viewer) ? COLOR_TOUCHED + : super.renderSidebarStatusColor(competitor, viewer); + } + + @Override + public String renderSidebarStatusText(@Nullable Competitor competitor, Party viewer) { + return shouldShowTouched(competitor, viewer) ? SYMBOL_TOUCHED + : super.renderSidebarStatusText(competitor, viewer); + } + + public boolean isTouched() { + return touched; + } + + /** Gets whether or not the specified team has touched the goal since the last reset. */ + public boolean hasTouched(Competitor team) { + return touchingCompetitors.contains(team); + } + + public boolean hasTouched(ParticipantState player) { + return touchingPlayers.contains(player); + } + + public ImmutableSet getTouchingPlayers() { + return ImmutableSet.copyOf(touchingPlayers); + } + + /** Gets whether or not the specified player has recently (in their current lifetime) touched the goal. */ + public boolean hasTouchedRecently(final ParticipantState player) { + return recentTouchingPlayers.contains(player); + } + + /** + * Gets whether or not the specified player touching the goal has any significance at this moment. + */ + public boolean canTouch(final ParticipantState player) { + return canComplete(player.getParty()) && + !isCompleted(player.getParty()) && + !hasTouchedRecently(player); + } + + public void touch(final @Nullable ParticipantState toucher) { + // TODO: support playerless touches (deduce which team to give the touch to based on objective owner etc) + if(toucher == null) return; + + touched = true; + + GoalTouchEvent event; + if(toucher == null) { + event = new GoalTouchEvent(this, getMatch().getClock().now().instant); + } else { + if(!canTouch(toucher)) return; + + boolean firstForCompetitor = touchingCompetitors.add(toucher.getParty()); + boolean firstForPlayer = touchingPlayers.add(toucher); + boolean firstForPlayerLife = recentTouchingPlayers.add(toucher); + + event = new GoalTouchEvent(this, + toucher.getParty(), firstForCompetitor, + toucher, firstForPlayer, firstForPlayerLife, + getMatch().getClock().now().instant); + } + + getMatch().callEvent(event); + sendTouchMessage(toucher, !event.getCancelToucherMessage()); + playTouchEffects(toucher); + } + + public void resetTouches() { + touched = false; + touchingCompetitors.clear(); + touchingPlayers.clear(); + recentTouchingPlayers.clear(); + } + + public void resetTouches(Competitor team) { + if(touchingCompetitors.remove(team)) { + for(Iterator iterator = touchingPlayers.iterator(); iterator.hasNext(); ) { + if(iterator.next().getParty() == team) iterator.remove();; + } + for(Iterator iterator = recentTouchingPlayers.iterator(); iterator.hasNext(); ) { + if(iterator.next().getParty() == team) iterator.remove();; + } + } + } + + @Override + public @Nullable ProximityMetric getProximityMetric(Competitor team) { + if(hasTouched(team)) { + return getDefinition().getPostTouchMetric(); + } else { + return super.getProximityMetric(team); + } + } + + public boolean showEnemyTouches() { + return false; + } + + public boolean shouldShowTouched(@Nullable Competitor team, Party viewer) { + return team != null && + !isCompleted(team) && + hasTouched(team) && + (team == viewer || showEnemyTouches() || viewer.isObservingType()); + } + + protected void sendTouchMessage(@Nullable ParticipantState toucher, boolean includeToucher) { + if(!isVisible()) return; + + BaseComponent message = getTouchMessage(toucher, false); + ComponentRenderers.send(Bukkit.getConsoleSender(), message); + + if(!showEnemyTouches()) { + message = new Component(toucher.getParty().getChatPrefix(), message); + } + + for(MatchPlayer viewer : getMatch().getPlayers()) { + if(shouldShowTouched(toucher.getParty(), viewer.getParty()) && (toucher == null || !toucher.isPlayer(viewer))) { + viewer.sendMessage(message); + } + } + + if(toucher != null) { + if(includeToucher) { + toucher.getAudience().sendMessage(getTouchMessage(toucher, true)); + } + + if(getDeferTouches()) { + toucher.getAudience().sendMessage(new TranslatableComponent("match.touch.destroyable.deferredNotice")); + } + } + } + + protected void playTouchEffects(@Nullable ParticipantState toucher) { + if(toucher == null || !isVisible()) return; + + MatchPlayer onlineToucher = toucher.getMatchPlayer(); + if(onlineToucher == null) return; + + onlineToucher.playSparks(); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onPlayerDeath(ParticipantDespawnEvent event) { + ParticipantState victim = event.getPlayer().getParticipantState(); + if(victim != null) recentTouchingPlayers.remove(victim); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onCompetitorRemove(CompetitorRemoveEvent event) { + resetTouches(event.getCompetitor()); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onComplete(GoalCompleteEvent event) { + if(this == event.getGoal()) { + resetTouches(); + } + } + + @Override + public MatchDoc.TouchableGoal getDocument() { + return new Document(); + } + + public class Document extends OwnedGoal.Document implements MatchDoc.TouchableGoal { + @Override + public Collection proximities() { + return Collections2.transform( + getMatch().getCompetitors(), + new Function() { + @Override + public MatchDoc.TouchableGoal.Proximity apply(Competitor competitor) { + return new Proximity(competitor); + } + } + ); + } + + public class Proximity implements MatchDoc.TouchableGoal.Proximity { + private final Competitor competitor; + + @Override + public String _id() { + return competitor.getId(); + } + + public Proximity(Competitor competitor) { + this.competitor = competitor; + } + + @Override + public boolean touched() { + return hasTouched(competitor); + } + + @Override + public Metric metric() { + final ProximityMetric metric = getProximityMetric(competitor); + return metric == null ? null : metric.apiValue(); + } + + @Override + public double distance() { + return getMinimumDistance(competitor); + } + } + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/goals/events/GoalCompleteEvent.java b/PGM/src/main/java/tc/oc/pgm/goals/events/GoalCompleteEvent.java new file mode 100644 index 0000000..4e4f663 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/goals/events/GoalCompleteEvent.java @@ -0,0 +1,56 @@ +package tc.oc.pgm.goals.events; + +import java.util.Collections; +import java.util.List; +import java.util.function.Predicate; + +import com.google.common.collect.ImmutableList; +import tc.oc.pgm.goals.Contribution; +import tc.oc.pgm.goals.Goal; +import tc.oc.pgm.match.Competitor; + +public class GoalCompleteEvent extends GoalEvent { + + private final boolean completed; + private final Predicate wasCompletedFor, isCompletedFor; + private final ImmutableList contributions; + + public GoalCompleteEvent(T goal, boolean completed, Predicate wasCompletedFor, Predicate isCompletedFor) { + this(goal, completed, wasCompletedFor, isCompletedFor, Collections.emptyList()); + } + + public GoalCompleteEvent(T goal, boolean completed, Predicate wasCompletedFor, Predicate isCompletedFor, List contributions) { + super(goal); + this.completed = completed; + this.wasCompletedFor = wasCompletedFor; + this.isCompletedFor = isCompletedFor; + this.contributions = ImmutableList.copyOf(contributions); + } + + public ImmutableList getContributions() { + return contributions; + } + + public boolean isCompleted() { + return completed; + } + + public Predicate wasCompletedFor() { + return wasCompletedFor; + } + + public Predicate isCompletedFor() { + return isCompletedFor; + } + + public boolean wasCompletedFor(Competitor competitor) { + return wasCompletedFor.test(competitor); + } + + /** + * @return true if the event was beneficial to the affected team, false if it was detrimental + */ + public boolean isCompletedFor(Competitor competitor) { + return isCompletedFor.test(competitor); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/goals/events/GoalEvent.java b/PGM/src/main/java/tc/oc/pgm/goals/events/GoalEvent.java new file mode 100644 index 0000000..86e1092 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/goals/events/GoalEvent.java @@ -0,0 +1,30 @@ +package tc.oc.pgm.goals.events; + +import org.bukkit.event.HandlerList; +import tc.oc.pgm.events.MatchEvent; +import tc.oc.pgm.goals.Goal; + +public abstract class GoalEvent extends MatchEvent { + private final T goal; + + protected GoalEvent(T goal) { + super(goal.getMatch()); + this.goal = goal; + } + + public T getGoal() { + return goal; + } + + private static final HandlerList handlers = new HandlerList(); + + public static HandlerList getHandlerList() { + return handlers; + } + + @Override + public HandlerList getHandlers() { + return handlers; + } + +} diff --git a/PGM/src/main/java/tc/oc/pgm/goals/events/GoalProximityChangeEvent.java b/PGM/src/main/java/tc/oc/pgm/goals/events/GoalProximityChangeEvent.java new file mode 100644 index 0000000..996da8f --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/goals/events/GoalProximityChangeEvent.java @@ -0,0 +1,52 @@ +package tc.oc.pgm.goals.events; + +import javax.annotation.Nullable; + +import org.bukkit.Location; +import org.bukkit.event.HandlerList; +import tc.oc.pgm.match.Competitor; +import tc.oc.pgm.goals.ProximityGoal; + +import static com.google.common.base.Preconditions.checkNotNull; + +public class GoalProximityChangeEvent extends GoalEvent { + private final Competitor competitor; + private final @Nullable Location location; + private final double oldDistance; + private final double newDistance; + + public GoalProximityChangeEvent(ProximityGoal goal, Competitor competitor, @Nullable Location location, double oldDistance, double newDistance) { + super(goal); + this.competitor = checkNotNull(competitor); + this.location = location; + this.oldDistance = oldDistance; + this.newDistance = newDistance; + } + + public Competitor getCompetitor() { + return competitor; + } + + public @Nullable Location getLocation() { + return this.location; + } + + public double getOldDistance() { + return this.oldDistance; + } + + public double getNewDistance() { + return this.newDistance; + } + + private static final HandlerList handlers = new HandlerList(); + + public static HandlerList getHandlerList() { + return handlers; + } + + @Override + public HandlerList getHandlers() { + return handlers; + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/goals/events/GoalStatusChangeEvent.java b/PGM/src/main/java/tc/oc/pgm/goals/events/GoalStatusChangeEvent.java new file mode 100644 index 0000000..a3ffcad --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/goals/events/GoalStatusChangeEvent.java @@ -0,0 +1,10 @@ +package tc.oc.pgm.goals.events; + +import tc.oc.pgm.goals.Goal; + +public class GoalStatusChangeEvent extends GoalEvent { + + public GoalStatusChangeEvent(Goal goal) { + super(goal); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/goals/events/GoalTouchEvent.java b/PGM/src/main/java/tc/oc/pgm/goals/events/GoalTouchEvent.java new file mode 100644 index 0000000..bd0887d --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/goals/events/GoalTouchEvent.java @@ -0,0 +1,101 @@ +package tc.oc.pgm.goals.events; + +import javax.annotation.Nullable; + +import com.google.common.base.Preconditions; +import org.bukkit.event.HandlerList; +import java.time.Instant; +import tc.oc.pgm.goals.TouchableGoal; +import tc.oc.pgm.match.Competitor; +import tc.oc.pgm.match.ParticipantState; + +/** + * Raised when a player touches a goal. + */ +public class GoalTouchEvent extends GoalEvent { + private static final HandlerList handlers = new HandlerList(); + + private final TouchableGoal goal; + private final @Nullable Competitor competitor; + private final boolean firstForCompetitor; + private final @Nullable ParticipantState player; + private final boolean firstForPlayer; + private final boolean firstForPlayerLife; + private final Instant time; + private boolean cancelToucherMessage; + + /** + * Creates a new {@link GoalTouchEvent}. + * @param goal The {@link TouchableGoal} that was touched. + * @param competitor Team that touched the goal, only if it was their first + * @param firstForCompetitor + * @param player The player that touched the goal. + * @param firstForPlayer + * @param firstForPlayerLife + * @param time The time at which the touch occurred. + */ + public GoalTouchEvent(TouchableGoal goal, + @Nullable Competitor competitor, boolean firstForCompetitor, + @Nullable ParticipantState player, boolean firstForPlayer, boolean firstForPlayerLife, + Instant time) { + + super(goal); + this.competitor = competitor; + this.firstForCompetitor = firstForCompetitor; + this.firstForPlayer = firstForPlayer; + this.firstForPlayerLife = firstForPlayerLife; + this.goal = Preconditions.checkNotNull(goal, "Goal"); + this.player = player; + this.time = Preconditions.checkNotNull(time, "Time"); + } + + public GoalTouchEvent(TouchableGoal goal, Instant time) { + this(goal, null, false, null, false, false, time); + } + + public Instant getTime() { + return this.time; + } + + public Competitor getCompetitor() { + return competitor; + } + + public boolean isFirstForCompetitor() { + return firstForCompetitor; + } + + public @Nullable ParticipantState getPlayer() { + return this.player; + } + + public boolean isFirstForPlayer() { + return firstForPlayer; + } + + public boolean isFirstForPlayerLife() { + return firstForPlayerLife; + } + + @Override + public TouchableGoal getGoal() { + return this.goal; + } + + public boolean getCancelToucherMessage() { + return cancelToucherMessage; + } + + public void setCancelToucherMessage(boolean cancelToucherMessage) { + this.cancelToucherMessage = cancelToucherMessage; + } + + public static HandlerList getHandlerList() { + return handlers; + } + + @Override + public HandlerList getHandlers() { + return handlers; + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/hunger/HungerMatchModule.java b/PGM/src/main/java/tc/oc/pgm/hunger/HungerMatchModule.java new file mode 100644 index 0000000..0c383ab --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/hunger/HungerMatchModule.java @@ -0,0 +1,25 @@ +package tc.oc.pgm.hunger; + +import org.bukkit.entity.Player; +import org.bukkit.event.*; +import org.bukkit.event.entity.FoodLevelChangeEvent; + +import tc.oc.pgm.match.Match; +import tc.oc.pgm.match.MatchScope; +import tc.oc.pgm.events.ListenerScope; +import tc.oc.pgm.match.MatchModule; + +@ListenerScope(MatchScope.RUNNING) +public class HungerMatchModule extends MatchModule implements Listener { + public HungerMatchModule(Match match) { + super(match); + } + + @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) + public void handleHungerChange(final FoodLevelChangeEvent event) { + if(event.getEntity() instanceof Player) { + int oldFoodLevel = ((Player) event.getEntity()).getFoodLevel(); + event.setFoodLevel(oldFoodLevel); + } + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/hunger/HungerModule.java b/PGM/src/main/java/tc/oc/pgm/hunger/HungerModule.java new file mode 100644 index 0000000..bd66fd6 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/hunger/HungerModule.java @@ -0,0 +1,36 @@ +package tc.oc.pgm.hunger; + +import java.util.logging.Logger; + +import org.jdom2.Document; +import org.jdom2.Element; +import tc.oc.pgm.map.MapModule; +import tc.oc.pgm.map.MapModuleContext; +import tc.oc.pgm.match.Match; +import tc.oc.pgm.match.MatchModuleFactory; +import tc.oc.pgm.module.ModuleDescription; +import tc.oc.pgm.utils.XMLUtils; +import tc.oc.pgm.xml.InvalidXMLException; + +@ModuleDescription(name="Hunger") +public class HungerModule implements MapModule, MatchModuleFactory { + @Override + public HungerMatchModule createMatchModule(Match match) { + return new HungerMatchModule(match); + } + + public static HungerModule parse(MapModuleContext context, Logger logger, Document doc) throws InvalidXMLException { + boolean depletion = true; + + for(Element elHunger : doc.getRootElement().getChildren("hunger")) { + depletion = XMLUtils.parseBoolean(elHunger, "depletion") + .optional(depletion); + } + + if(!depletion) { + return new HungerModule(); + } else { + return null; + } + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/inventory/InventoryCommands.java b/PGM/src/main/java/tc/oc/pgm/inventory/InventoryCommands.java new file mode 100644 index 0000000..31e7971 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/inventory/InventoryCommands.java @@ -0,0 +1,43 @@ +package tc.oc.pgm.inventory; + +import javax.inject.Inject; + +import com.sk89q.minecraft.util.commands.Command; +import com.sk89q.minecraft.util.commands.CommandContext; +import com.sk89q.minecraft.util.commands.CommandException; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import tc.oc.commons.core.commands.Commands; +import tc.oc.commons.core.commands.TranslatableCommandException; +import tc.oc.pgm.match.inject.MatchScoped; + +import static tc.oc.commons.bukkit.commands.CommandUtils.findOnlinePlayer; +import static tc.oc.commons.bukkit.commands.CommandUtils.senderToPlayer; + +@MatchScoped +public class InventoryCommands implements Commands { + + private final ViewInventoryMatchModule vimm; + + @Inject InventoryCommands(ViewInventoryMatchModule vimm) { + this.vimm = vimm; + } + + @Command( + aliases = {"inventory", "inv", "vi"}, + desc = "View a player's inventory", + usage = "", + min = 1, + max = 1 + ) + public void inventory(CommandContext args, CommandSender sender) throws CommandException { + final Player viewer = senderToPlayer(sender); + Player holder = findOnlinePlayer(args, viewer, 0); + + if(vimm.canPreviewInventory(viewer, holder)) { + vimm.previewInventory((Player) sender, holder.getInventory()); + } else { + throw new TranslatableCommandException("player.inventoryPreview.notViewable"); + } + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/inventory/ViewInventoryMatchModule.java b/PGM/src/main/java/tc/oc/pgm/inventory/ViewInventoryMatchModule.java new file mode 100644 index 0000000..9a6c527 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/inventory/ViewInventoryMatchModule.java @@ -0,0 +1,422 @@ +package tc.oc.pgm.inventory; + +import java.time.Duration; +import java.time.Instant; +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 com.google.common.collect.Lists; +import org.apache.commons.lang.StringUtils; +import org.bukkit.Bukkit; +import org.bukkit.ChatColor; +import org.bukkit.Material; +import org.bukkit.attribute.Attribute; +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.EntityDamageEvent; +import org.bukkit.event.entity.EntityRegainHealthEvent; +import org.bukkit.event.entity.FoodLevelChangeEvent; +import org.bukkit.event.inventory.ClickType; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryClickedEvent; +import org.bukkit.event.inventory.InventoryCloseEvent; +import org.bukkit.event.inventory.InventoryDragEvent; +import org.bukkit.event.inventory.InventoryType; +import org.bukkit.event.player.PlayerDropItemEvent; +import org.bukkit.event.player.PlayerPickupItemEvent; +import org.bukkit.inventory.DoubleChestInventory; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.bukkit.inventory.ItemFlag; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.potion.PotionEffect; +import tc.oc.api.bukkit.users.Users; +import tc.oc.commons.bukkit.util.BukkitUtils; +import tc.oc.commons.core.commands.CommandBinder; +import tc.oc.pgm.PGMTranslations; +import tc.oc.pgm.blitz.BlitzMatchModule; +import tc.oc.pgm.doublejump.DoubleJumpMatchModule; +import tc.oc.pgm.events.ListenerScope; +import tc.oc.pgm.events.ObserverInteractEvent; +import tc.oc.pgm.events.PlayerBlockTransformEvent; +import tc.oc.pgm.events.PlayerPartyChangeEvent; +import tc.oc.pgm.kits.WalkSpeedKit; +import tc.oc.pgm.match.MatchModule; +import tc.oc.pgm.match.MatchPlayer; +import tc.oc.pgm.match.MatchScope; +import tc.oc.pgm.match.Repeatable; +import tc.oc.pgm.match.inject.MatchModuleFixtureManifest; +import tc.oc.pgm.spawns.events.ParticipantSpawnEvent; +import tc.oc.time.Time; + +@ListenerScope(MatchScope.LOADED) +public class ViewInventoryMatchModule extends MatchModule implements Listener { + + public static class Manifest extends MatchModuleFixtureManifest { + @Override protected void configure() { + super.configure(); + + new CommandBinder(binder()) + .register(InventoryCommands.class); + } + } + + public static final Duration TICK = Duration.ofMillis(50); + + protected final Map views = new HashMap<>(); + protected final Map updateQueue = new HashMap<>(); + + public static int getInventoryPreviewSlot(int inventorySlot) { + if(inventorySlot < 9) { + return inventorySlot + 36; // put hotbar on bottom + } + if(inventorySlot < 36) { + return inventorySlot; // rest of inventory + } + // TODO: investigate why this method doesn't work with CraftBukkit's armor slots + return inventorySlot; // default + } + + @Repeatable(scope = MatchScope.LOADED, interval = @Time(ticks = 4)) + public void queuedChecks() { + for(Iterator> iterator = updateQueue.entrySet().iterator(); iterator.hasNext();) { + final Map.Entry entry = iterator.next(); + if(entry.getValue().isAfter(Instant.now())) continue; + + checkMonitoredInventories(entry.getKey()); + iterator.remove(); + } + } + + @EventHandler + public void closeMonitoredInventory(final InventoryCloseEvent event) { + views.remove(event.getActor()); + } + + @EventHandler + public void playerQuit(final PlayerPartyChangeEvent event) { + views.remove(event.getPlayer().getBukkit()); + } + + @EventHandler(ignoreCancelled = true) + public void showInventories(final ObserverInteractEvent event) { + if(event.getClickType() != ClickType.RIGHT) return; + if(event.getPlayer().isDead()) return; + + if(event.getClickedParticipant() != null) { + event.setCancelled(true); + if(canPreviewInventory(event.getPlayer(), event.getClickedParticipant())) { + this.previewPlayerInventory(event.getPlayer().getBukkit(), event.getClickedParticipant().getInventory()); + } + } else if(event.getClickedEntity() instanceof InventoryHolder && !(event.getClickedEntity() instanceof Player)) { + event.setCancelled(true); + this.previewInventory(event.getPlayer().getBukkit(), ((InventoryHolder) event.getClickedEntity()).getInventory()); + } else if(event.getClickedBlockState() instanceof InventoryHolder) { + event.setCancelled(true); + this.previewInventory(event.getPlayer().getBukkit(), ((InventoryHolder) event.getClickedBlockState()).getInventory()); + } + } + + @EventHandler(priority = EventPriority.LOW, ignoreCancelled = true) + public void cancelClicks(final InventoryClickEvent event) { + final View view = views.get(event.getActor()); + if(view != null && event.getInventory().equals(view.preview)) { + event.setCancelled(true); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void updateMonitoredClick(final InventoryClickedEvent event) { + if(event.getWhoClicked() instanceof Player) { + Player player = (Player) event.getWhoClicked(); + + boolean playerInventory = event.getInventory().getType() == InventoryType.CRAFTING; // cb bug fix + Inventory inventory; + + if(playerInventory) { + inventory = player.getInventory(); + } else { + inventory = event.getInventory(); + } + + invLoop: for(Map.Entry entry : new HashSet<>(this.views.entrySet())) { // avoid ConcurrentModificationException + final Player viewer = entry.getKey(); + View view = entry.getValue(); + + // because a player can only be viewing one inventory at a time, + // this is how we determine if we have a match + if(inventory.getViewers().isEmpty() || + view.watched.getViewers().isEmpty() || + inventory.getViewers().size() > view.watched.getViewers().size()) continue invLoop; + + for(int i = 0; i < inventory.getViewers().size(); i++) { + if(!inventory.getViewers().get(i).equals(view.watched.getViewers().get(i))) { + continue invLoop; + } + } + + // a watched user is in a chest + if(view.isPlayerInventory() && !playerInventory) { + inventory = view.getPlayerInventory().getHolder().getInventory(); + playerInventory = true; + } + + if(playerInventory) { + this.previewPlayerInventory(viewer, (PlayerInventory) inventory); + } else { + this.previewInventory(viewer, inventory); + } + } + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void updateMonitoredInventory(final InventoryClickEvent event) { + this.scheduleCheck((Player) event.getWhoClicked()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void updateMonitoredInventory(final InventoryDragEvent event) { + this.scheduleCheck((Player) event.getWhoClicked()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void updateMonitoredTransform(final PlayerBlockTransformEvent event) { + MatchPlayer player = event.getPlayer(); + if(player != null) this.scheduleCheck(player.getBukkit()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void updateMonitoredPickup(final PlayerPickupItemEvent event) { + this.scheduleCheck(event.getPlayer()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void updateMonitoredDrop(final PlayerDropItemEvent event) { + this.scheduleCheck(event.getPlayer()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void updateMonitoredDamage(final EntityDamageEvent event) { + if(event.getEntity() instanceof Player) { + this.scheduleCheck((Player) event.getEntity()); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void updateMonitoredHealth(final EntityRegainHealthEvent event) { + if(event.getEntity() instanceof Player) { + Player player = (Player) event.getEntity(); + if(player.getHealth() == player.getMaxHealth()) return; + this.scheduleCheck((Player) event.getEntity()); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void updateMonitoredHunger(final FoodLevelChangeEvent event) { + this.scheduleCheck((Player) event.getEntity()); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void updateMonitoredSpawn(final ParticipantSpawnEvent event) { + // must have this hack so we update player's inventories when they respawn and recieve a kit + ViewInventoryMatchModule.this.scheduleCheck(event.getPlayer().getBukkit()); + } + + public boolean canPreviewInventory(Player viewer, Player holder) { + MatchPlayer matchViewer = getMatch().getPlayer(viewer); + MatchPlayer matchHolder = getMatch().getPlayer(holder); + return matchViewer != null && matchHolder != null && canPreviewInventory(matchViewer, matchHolder); + } + + public boolean canPreviewInventory(MatchPlayer viewer, MatchPlayer holder) { + return viewer.isObserving() && holder.isSpawned(); + } + + protected void scheduleCheck(Player updater) { + updateQueue.computeIfAbsent(updater, player -> Instant.now().plus(TICK)); + } + + protected void checkMonitoredInventories(Player updater) { + views.forEach((viewer, view) -> { + if(view.isPlayerInventory() && updater.equals(view.getPlayerInventory().getHolder())) { + previewPlayerInventory(viewer, view.getPlayerInventory()); + } + }); + } + + protected void previewPlayerInventory(Player viewer, PlayerInventory inventory) { + if(viewer == null) { return; } + + Player holder = (Player) inventory.getHolder(); + // Ensure that the title of the inventory is <= 32 characters long to appease Minecraft's restrictions on inventory titles + String title = StringUtils.substring(holder.getDisplayName(viewer), 0, 32); + + Inventory preview = Bukkit.getServer().createInventory(viewer, 45, title); + + // handle inventory mapping + for(int i = 0; i <= 35; i++) { + preview.setItem(getInventoryPreviewSlot(i), inventory.getItem(i)); + } + + MatchPlayer matchHolder = this.match.getPlayer(holder); + if (matchHolder != null && matchHolder.isParticipating()) { + BlitzMatchModule module = matchHolder.getMatch().getMatchModule(BlitzMatchModule.class); + if (module != null) { + int livesLeft = module.lifeManager.getLives(Users.playerId(holder)); + ItemStack lives = new ItemStack(Material.EGG, livesLeft); + ItemMeta lifeMeta = lives.getItemMeta(); + lifeMeta.addItemFlags(ItemFlag.values()); + String key = livesLeft == 1 ? "match.blitz.livesRemaining.singularLives" : "match.blitz.livesRemaining.pluralLives"; + lifeMeta.setDisplayName(ChatColor.GREEN + PGMTranslations.get().t(key, viewer, ChatColor.AQUA + String.valueOf(livesLeft) + ChatColor.GREEN)); + lives.setItemMeta(lifeMeta); + preview.setItem(4, lives); + } + + List specialLore = new ArrayList<>(); + + if(holder.getAllowFlight()) { + specialLore.add(ChatColor.LIGHT_PURPLE + PGMTranslations.get().t("specialAbility.flying", viewer)); + } + + DoubleJumpMatchModule djmm = matchHolder.getMatch().getMatchModule(DoubleJumpMatchModule.class); + if(djmm != null && djmm.hasKit(matchHolder)) { + specialLore.add(ChatColor.LIGHT_PURPLE + PGMTranslations.get().t("specialAbility.doubleJump", viewer)); + } + + double knockbackResistance = holder.getAttribute(Attribute.GENERIC_KNOCKBACK_RESISTANCE).getValue(); + if(knockbackResistance > 0) { + specialLore.add(ChatColor.LIGHT_PURPLE + PGMTranslations.get().t("specialAbility.knockbackResistance", viewer, (int) Math.ceil(knockbackResistance * 100))); + } + + double knockbackReduction = holder.getKnockbackReduction(); + if(knockbackReduction > 0) { + specialLore.add(ChatColor.LIGHT_PURPLE + PGMTranslations.get().t("specialAbility.knockbackReduction", viewer, (int) Math.ceil(knockbackReduction * 100))); + } + + double walkSpeed = holder.getWalkSpeed(); + if(walkSpeed != WalkSpeedKit.BUKKIT_DEFAULT) { + specialLore.add(ChatColor.LIGHT_PURPLE + PGMTranslations.get().t("specialAbility.walkSpeed", viewer, String.format("%.1f", walkSpeed / WalkSpeedKit.BUKKIT_DEFAULT))); + } + + + if(!specialLore.isEmpty()) { + ItemStack special = new ItemStack(Material.NETHER_STAR); + ItemMeta specialMeta = special.getItemMeta(); + specialMeta.addItemFlags(ItemFlag.values()); + specialMeta.setDisplayName(ChatColor.AQUA.toString() + ChatColor.ITALIC + PGMTranslations.get().t("player.inventoryPreview.specialAbilities", viewer)); + specialMeta.setLore(specialLore); + special.setItemMeta(specialMeta); + preview.setItem(5, special); + } + } + + // potions + boolean hasPotions = holder.getActivePotionEffects().size() > 0; + ItemStack potions = new ItemStack(hasPotions? Material.POTION : Material.GLASS_BOTTLE); + ItemMeta potionMeta = potions.getItemMeta(); + potionMeta.addItemFlags(ItemFlag.values()); + potionMeta.setDisplayName(ChatColor.AQUA.toString() + ChatColor.ITALIC + PGMTranslations.get().t("player.inventoryPreview.potionEffects", viewer)); + List lore = Lists.newArrayList(); + if(hasPotions) { + for(PotionEffect effect : holder.getActivePotionEffects()) { + lore.add(ChatColor.YELLOW + BukkitUtils.potionEffectTypeName(effect.getType()) + " " + (effect.getAmplifier() + 1)); + } + } else { + lore.add(ChatColor.YELLOW + PGMTranslations.get().t("player.inventoryPreview.noPotionEffects", viewer)); + } + potionMeta.setLore(lore); + potions.setItemMeta(potionMeta); + preview.setItem(6, potions); + + // hunger and health + ItemStack hunger = new ItemStack(Material.COOKED_BEEF, holder.getFoodLevel()); + ItemMeta hungerMeta = hunger.getItemMeta(); + hungerMeta.addItemFlags(ItemFlag.values()); + hungerMeta.setDisplayName(ChatColor.AQUA.toString() + ChatColor.ITALIC + PGMTranslations.get().t("player.inventoryPreview.hungerLevel", viewer)); + hungerMeta.addItemFlags(ItemFlag.HIDE_POTION_EFFECTS); + hunger.setItemMeta(hungerMeta); + preview.setItem(7, hunger); + + ItemStack health = new ItemStack(Material.REDSTONE, (int) holder.getHealth()); + ItemMeta healthMeta = health.getItemMeta(); + healthMeta.addItemFlags(ItemFlag.values()); + healthMeta.setDisplayName(ChatColor.AQUA.toString() + ChatColor.ITALIC + PGMTranslations.get().t("player.inventoryPreview.healthLevel", viewer)); + healthMeta.addItemFlags(ItemFlag.HIDE_POTION_EFFECTS); + health.setItemMeta(healthMeta); + preview.setItem(8, health); + + // set armor manually because craftbukkit is a derp + preview.setItem(0, inventory.getHelmet()); + preview.setItem(1, inventory.getChestplate()); + preview.setItem(2, inventory.getLeggings()); + preview.setItem(3, inventory.getBoots()); + + this.showInventoryPreview(viewer, inventory, preview); + } + + public void previewInventory(Player viewer, Inventory realInventory) { + if(viewer == null) { return; } + + if(realInventory instanceof PlayerInventory) { + previewPlayerInventory(viewer, (PlayerInventory) realInventory); + }else { + Inventory fakeInventory; + if(realInventory instanceof DoubleChestInventory) { + if(realInventory.hasCustomName()) { + fakeInventory = Bukkit.createInventory(viewer, realInventory.getSize(), realInventory.getName()); + } else { + fakeInventory = Bukkit.createInventory(viewer, realInventory.getSize()); + } + } else { + if(realInventory.hasCustomName()) { + fakeInventory = Bukkit.createInventory(viewer, realInventory.getType(), realInventory.getName()); + } else { + fakeInventory = Bukkit.createInventory(viewer, realInventory.getType()); + } + } + fakeInventory.setContents(realInventory.contents()); + + this.showInventoryPreview(viewer, realInventory, fakeInventory); + } + } + + protected void showInventoryPreview(Player viewer, Inventory realInventory, Inventory fakeInventory) { + if(viewer == null) return; + + View view = views.get(viewer); + if(view != null && view.watched.equals(realInventory) && view.preview.getSize() == fakeInventory.getSize()) { + view.preview.setContents(fakeInventory.contents()); + } else { + view = new View(realInventory, fakeInventory); + views.put(viewer, view); + viewer.openInventory(fakeInventory); + } + } + + private static class View { + final Inventory watched; + final Inventory preview; + + View(Inventory watched, Inventory preview) { + this.watched = watched; + this.preview = preview; + } + + boolean isPlayerInventory() { + return this.watched instanceof PlayerInventory; + } + + PlayerInventory getPlayerInventory() { + return (PlayerInventory) this.watched; + } + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/itemkeep/ItemKeepManifest.java b/PGM/src/main/java/tc/oc/pgm/itemkeep/ItemKeepManifest.java new file mode 100644 index 0000000..16b4264 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/itemkeep/ItemKeepManifest.java @@ -0,0 +1,20 @@ +package tc.oc.pgm.itemkeep; + +import tc.oc.commons.core.inject.HybridManifest; +import tc.oc.pgm.map.inject.MapBinders; +import tc.oc.pgm.match.MatchPlayerFacetBinder; +import tc.oc.pgm.match.inject.MatchBinders; + +public class ItemKeepManifest extends HybridManifest implements MapBinders, MatchBinders { + @Override + protected void configure() { + bindRootElementParser(ItemKeepRules.class) + .to(ItemKeepParser.class); + + installPlayerModule(binder -> { + new MatchPlayerFacetBinder(binder) + .register(ItemKeepPlayerFacet.class); + }); + } +} + diff --git a/PGM/src/main/java/tc/oc/pgm/itemkeep/ItemKeepParser.java b/PGM/src/main/java/tc/oc/pgm/itemkeep/ItemKeepParser.java new file mode 100644 index 0000000..97471e9 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/itemkeep/ItemKeepParser.java @@ -0,0 +1,35 @@ +package tc.oc.pgm.itemkeep; + +import java.util.Set; + +import com.google.common.collect.Sets; +import org.jdom2.Element; +import tc.oc.pgm.filters.matcher.block.MaterialFilter; +import tc.oc.pgm.filters.operator.AnyFilter; +import tc.oc.pgm.utils.XMLUtils; +import tc.oc.pgm.xml.InvalidXMLException; +import tc.oc.pgm.xml.Node; +import tc.oc.pgm.xml.parser.ElementParser; + +public class ItemKeepParser implements ElementParser { + + @Override + public ItemKeepRules parseElement(Element element) throws InvalidXMLException { + final Set itemFilters = Sets.newHashSet(); + for(Node elItemKeep : Node.fromChildren(element, "item-keep", "itemkeep")) { + for(Node elItem : Node.fromChildren(elItemKeep.asElement(), "item")) { + itemFilters.add(new MaterialFilter(XMLUtils.parseMaterialPattern(elItem))); + } + } + + final Set armorFilters = Sets.newHashSet(); + for(Node elArmorKeep : Node.fromChildren(element, "armor-keep", "armorkeep")) { + for(Node elItem : Node.fromChildren(elArmorKeep.asElement(), "item")) { + armorFilters.add(new MaterialFilter(XMLUtils.parseMaterialPattern(elItem))); + } + } + + return new ItemKeepRules(AnyFilter.of(itemFilters), + AnyFilter.of(armorFilters)); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/itemkeep/ItemKeepPlayerFacet.java b/PGM/src/main/java/tc/oc/pgm/itemkeep/ItemKeepPlayerFacet.java new file mode 100644 index 0000000..2a742de --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/itemkeep/ItemKeepPlayerFacet.java @@ -0,0 +1,88 @@ +package tc.oc.pgm.itemkeep; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import javax.inject.Inject; + +import org.bukkit.entity.Player; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.PlayerDeathEvent; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +import tc.oc.commons.bukkit.event.targeted.TargetedEventHandler; +import tc.oc.commons.bukkit.inventory.Slot; +import tc.oc.pgm.events.PlayerPartyChangeEvent; +import tc.oc.pgm.match.MatchPlayer; +import tc.oc.pgm.match.MatchPlayerFacet; + +public class ItemKeepPlayerFacet implements MatchPlayerFacet, Listener { + + private final MatchPlayer player; + private final Player bukkit; + private final ItemKeepRules rules; + private final Map kept = new HashMap<>(); + + @Inject ItemKeepPlayerFacet(MatchPlayer player, Player bukkit, ItemKeepRules rules) { + this.player = player; + this.bukkit = bukkit; + this.rules = rules; + } + + /** + * NOTE: Must be called before {@link tc.oc.pgm.tracker.trackers.DeathTracker#onPlayerDeath(PlayerDeathEvent)} + */ + @SuppressWarnings("deprecation") + @TargetedEventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true) + public void processPlayerDeath(PlayerDeathEvent event) { + if(!player.isParticipating()) return; + if(!rules.canKeepAny()) return; + + final Map carrying = new HashMap<>(); + Slot.Player.player().forEach(slot -> slot.item(event.getEntity()) + .ifPresent(item -> carrying.put(slot, item))); + + kept.clear(); + + carrying.forEach((slot, item) -> { + if(rules.canKeep(slot, item)) { + event.getDrops().remove(item); + kept.put(slot, item); + } + }); + } + + public void restoreKeptInventory() { + final List displaced = new ArrayList<>(); + final PlayerInventory inv = bukkit.getInventory(); + + kept.forEach((slot, keptStack) -> { + final ItemStack invStack = slot.getItem(bukkit); + + if(invStack == null || slot instanceof Slot.Armor) { + slot.putItem(inv, keptStack); + } else { + if(invStack.isSimilar(keptStack)) { + int n = Math.min(keptStack.getAmount(), invStack.getMaxStackSize() - invStack.getAmount()); + invStack.setAmount(invStack.getAmount() + n); + keptStack.setAmount(keptStack.getAmount() - n); + } + if(keptStack.getAmount() > 0) { + displaced.add(keptStack); + } + } + + for(ItemStack stack : displaced) { + inv.addItem(stack); + } + }); + kept.clear(); + } + + @TargetedEventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void partyChange(PlayerPartyChangeEvent event) { + kept.clear(); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/itemkeep/ItemKeepRules.java b/PGM/src/main/java/tc/oc/pgm/itemkeep/ItemKeepRules.java new file mode 100644 index 0000000..8c92190 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/itemkeep/ItemKeepRules.java @@ -0,0 +1,28 @@ +package tc.oc.pgm.itemkeep; + +import org.bukkit.inventory.ItemStack; +import tc.oc.commons.bukkit.inventory.Slot; +import tc.oc.pgm.filters.Filter; +import tc.oc.pgm.filters.matcher.StaticFilter; +import tc.oc.pgm.filters.query.MaterialQuery; + +public class ItemKeepRules { + public final Filter itemFilter, armorFilter; + + public ItemKeepRules(Filter itemFilter, Filter armorFilter) { + this.itemFilter = itemFilter; + this.armorFilter = armorFilter; + } + + public boolean canKeepAny() { + return !StaticFilter.DENY.equals(itemFilter) || + !StaticFilter.DENY.equals(armorFilter); + } + + public boolean canKeep(Slot slot, ItemStack item) { + final MaterialQuery query = MaterialQuery.of(item.getData()); + return itemFilter.query(query).toBoolean(false) || + (slot instanceof Slot.Armor && + armorFilter.query(query).toBoolean(false)); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/itemmeta/ItemModifier.java b/PGM/src/main/java/tc/oc/pgm/itemmeta/ItemModifier.java new file mode 100644 index 0000000..8be208f --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/itemmeta/ItemModifier.java @@ -0,0 +1,33 @@ +package tc.oc.pgm.itemmeta; + +import java.util.Optional; +import javax.inject.Inject; + +import org.bukkit.inventory.ItemStack; + +public class ItemModifier { + + private final Optional module; + + @Inject ItemModifier(Optional module) { + this.module = module; + } + + public boolean needsModification(ItemStack stack) { + return module.isPresent() && module.get().shouldApply(stack); + } + + public ItemStack modifyCopy(ItemStack stack) { + if(needsModification(stack)) { + stack = module.get().applyToCopy(stack); + } + return stack; + } + + public ItemStack modify(ItemStack stack) { + if(needsModification(stack)) { + module.get().applyRules(stack); + } + return stack; + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/itemmeta/ItemModifyMatchModule.java b/PGM/src/main/java/tc/oc/pgm/itemmeta/ItemModifyMatchModule.java new file mode 100644 index 0000000..271a0b8 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/itemmeta/ItemModifyMatchModule.java @@ -0,0 +1,78 @@ +package tc.oc.pgm.itemmeta; + +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.block.BlockDispenseEvent; +import org.bukkit.event.entity.ItemSpawnEvent; +import org.bukkit.event.inventory.CraftItemEvent; +import org.bukkit.event.inventory.InventoryOpenEvent; +import org.bukkit.event.inventory.PrepareItemCraftEvent; +import org.bukkit.event.player.PlayerPickupArrowEvent; +import org.bukkit.inventory.ItemStack; +import tc.oc.pgm.events.ListenerScope; +import tc.oc.pgm.match.Match; +import tc.oc.pgm.match.MatchModule; +import tc.oc.pgm.match.MatchScope; + +@ListenerScope(MatchScope.LOADED) +public class ItemModifyMatchModule extends MatchModule implements Listener { + + private final ItemModifyModule imm; + + public ItemModifyMatchModule(Match match) { + super(match); + this.imm = match.getModuleContext().needModule(ItemModifyModule.class); + } + + private boolean applyRules(ItemStack stack) { + return imm.applyRules(stack); + } + + @EventHandler + public void onItemSpawn(ItemSpawnEvent event) { + ItemStack stack = event.getEntity().getItemStack(); + if(applyRules(stack)) { + event.getEntity().setItemStack(stack); + } + } + + @EventHandler + public void onItemCraft(CraftItemEvent event) { + ItemStack stack = event.getCurrentItem(); + if(applyRules(stack)) { + event.setCurrentItem(stack); + event.getInventory().setResult(stack); + } + } + + @EventHandler + public void onPrepareItemCraft(PrepareItemCraftEvent event) { + ItemStack stack = event.getInventory().getResult(); + if(applyRules(stack)) { + event.getInventory().setResult(stack); + } + } + + @EventHandler + public void onInventoryOpen(InventoryOpenEvent event) { + event.getInventory().contents().forEach(this::applyRules); + } + + @EventHandler + public void onArmorDispense(BlockDispenseEvent event) { + // This covers armor being equipped by a dispenser, which does not call any of the other events + ItemStack stack = event.getItem(); + if(applyRules(stack)) { + event.setItem(stack); + } + } + + @EventHandler + public void onArrowPickup(PlayerPickupArrowEvent event) { + // Only needed for players picking up arrows stuck in blocks + final ItemStack item = event.getItem().getItemStack(); + if(applyRules(item)) { + event.getItem().setItemStack(item); + } + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/itemmeta/ItemModifyModule.java b/PGM/src/main/java/tc/oc/pgm/itemmeta/ItemModifyModule.java new file mode 100644 index 0000000..d538f65 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/itemmeta/ItemModifyModule.java @@ -0,0 +1,114 @@ +package tc.oc.pgm.itemmeta; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.logging.Logger; +import javax.annotation.Nullable; + +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.attribute.ItemAttributeModifier; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.inventory.meta.PotionMeta; +import org.jdom2.Document; +import org.jdom2.Element; +import tc.oc.commons.bukkit.item.BooleanItemTag; +import tc.oc.pgm.kits.ItemParser; +import tc.oc.pgm.map.MapModule; +import tc.oc.pgm.map.MapModuleContext; +import tc.oc.pgm.map.MapModuleFactory; +import tc.oc.pgm.match.Match; +import tc.oc.pgm.match.MatchModuleFactory; +import tc.oc.pgm.module.ModuleDescription; +import tc.oc.pgm.utils.MaterialMatcher; +import tc.oc.pgm.utils.XMLUtils; +import tc.oc.pgm.xml.InvalidXMLException; + +@ModuleDescription(name = "Item Modify") +public class ItemModifyModule implements MapModule, MatchModuleFactory { + private static final BooleanItemTag APPLIED = new BooleanItemTag("custom-meta-applied", false); + + private final List rules; + + public ItemModifyModule(List rules) { + this.rules = rules; + } + + public boolean shouldApply(ItemStack stack) { + return stack != null && + stack.getType() != Material.AIR && + !APPLIED.get(stack); + } + + public ItemStack applyToCopy(ItemStack stack) { + if(shouldApply(stack)) { + final boolean immutable = stack.isImmutable(); + stack = stack.clone(); + applyRules(stack); + if(immutable) { + stack = stack.immutableCopy(); + } + } + return stack; + } + + public boolean applyRules(ItemStack stack) { + if(!shouldApply(stack)) { + return false; + } else { + boolean defaultAttributes = false; + boolean attributesModified = false; + + for(ItemRule rule : rules) { + if(rule.matches(stack)) { + rule.apply(stack); + APPLIED.set(stack, true); + attributesModified |= rule.meta.hasAttributeModifiers(); + defaultAttributes |= rule.defaultAttributes; + } + } + + // If any rule had the defaultAttributes flag, and any custom attributes were added, + // add the default attributes now. We do this here so it only happens once. + if(defaultAttributes && attributesModified) { + final ItemMeta meta = stack.getItemMeta(); + for(Map.Entry> entry : Bukkit.getItemFactory().getAttributeModifiers(stack.getData()).entrySet()) { + for(ItemAttributeModifier mod : entry.getValue()) { + meta.addAttributeModifier(entry.getKey(), mod); + } + } + stack.setItemMeta(meta); + } + + return true; + } + } + + @Override + public ItemModifyMatchModule createMatchModule(Match match) { + return new ItemModifyMatchModule(match); + } + + public static class Factory extends MapModuleFactory { + @Override + public @Nullable ItemModifyModule parse(MapModuleContext context, Logger logger, Document doc) throws InvalidXMLException { + List rules = new ArrayList<>(); + for(Element elRule : XMLUtils.flattenElements(doc.getRootElement(), "item-mods", "rule")) { + MaterialMatcher items = XMLUtils.parseMaterialMatcher(XMLUtils.getRequiredUniqueChild(elRule, "match")); + + // Always use a PotionMeta so the rule can have potion effects, though it will only apply those to potion items + final Element elModify = XMLUtils.getRequiredUniqueChild(elRule, "modify"); + final PotionMeta meta = (PotionMeta) Bukkit.getItemFactory().getItemMeta(Material.POTION); + context.needModule(ItemParser.class).parseItemMeta(elModify, meta); + final boolean defaultAttributes = XMLUtils.parseBoolean(elModify.getAttribute("default-attributes"), false); + + ItemRule rule = new ItemRule(items, meta, defaultAttributes); + rules.add(rule); + } + + return rules.isEmpty() ? null : new ItemModifyModule(rules); + } + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/itemmeta/ItemRule.java b/PGM/src/main/java/tc/oc/pgm/itemmeta/ItemRule.java new file mode 100644 index 0000000..9ac27b2 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/itemmeta/ItemRule.java @@ -0,0 +1,59 @@ +package tc.oc.pgm.itemmeta; + +import java.util.Set; + +import org.bukkit.attribute.ItemAttributeModifier; +import org.bukkit.inventory.ItemFlag; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.inventory.meta.PotionMeta; +import tc.oc.commons.bukkit.item.ItemUtils; +import tc.oc.pgm.utils.MaterialMatcher; + +public class ItemRule { + final MaterialMatcher items; + final PotionMeta meta; + final boolean defaultAttributes; + + public ItemRule(MaterialMatcher items, PotionMeta meta, boolean defaultAttributes) { + this.items = items; + this.meta = meta; + this.defaultAttributes = defaultAttributes; + } + + public boolean matches(ItemStack stack) { + return items.matches(stack); + } + + public void apply(ItemStack stack) { + ItemUtils.addPotionEffects(stack, this.meta.getCustomEffects()); + + ItemMeta meta = stack.getItemMeta(); + if(meta != null) { + if(this.meta.hasDisplayName()) { + meta.setDisplayName(this.meta.getDisplayName()); + } + + if(this.meta.hasLore()) { + meta.setLore(this.meta.getLore()); + } + + Set flags = this.meta.getItemFlags(); + meta.addItemFlags(flags.toArray(new ItemFlag[flags.size()])); + + ItemUtils.addEnchantments(meta, this.meta.getEnchants()); + + for(String attribute : this.meta.getModifiedAttributes()) { + for(ItemAttributeModifier modifier : this.meta.getAttributeModifiers(attribute)) { + meta.addAttributeModifier(attribute, modifier); + } + } + + if(this.meta.isUnbreakable()) meta.setUnbreakable(true); + meta.setCanDestroy(ItemUtils.unionMaterials(meta.getCanDestroy(), this.meta.getCanDestroy())); + meta.setCanPlaceOn(ItemUtils.unionMaterials(meta.getCanPlaceOn(), this.meta.getCanPlaceOn())); + + stack.setItemMeta(meta); + } + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/join/JoinAllowed.java b/PGM/src/main/java/tc/oc/pgm/join/JoinAllowed.java new file mode 100644 index 0000000..2fe1128 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/join/JoinAllowed.java @@ -0,0 +1,51 @@ +package tc.oc.pgm.join; + +import java.util.Optional; + +import tc.oc.pgm.match.Competitor; + +public class JoinAllowed implements JoinResult { + + private final Optional competitor; + private final boolean rejoin; + private final boolean priorityKick; + + protected JoinAllowed(Optional competitor, boolean rejoin, boolean priorityKick) { + this.competitor = competitor; + this.rejoin = rejoin; + this.priorityKick = priorityKick; + } + + public static JoinAllowed auto(boolean priorityKick) { + return new JoinAllowed(Optional.empty(), false, priorityKick); + } + + public static JoinAllowed force(JoinResult result) { + return new JoinAllowed(result.competitor(), result.isRejoin(), result.priorityKickRequired()); + } + + @Override + public boolean priorityKickRequired() { + return priorityKick; + } + + @Override + public Optional competitor() { + return competitor; + } + + @Override + public boolean isRejoin() { + return rejoin; + } + + @Override + public boolean isVisible() { + return true; + } + + @Override + public boolean isAllowed() { + return true; + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/join/JoinCommands.java b/PGM/src/main/java/tc/oc/pgm/join/JoinCommands.java new file mode 100644 index 0000000..0812813 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/join/JoinCommands.java @@ -0,0 +1,65 @@ +package tc.oc.pgm.join; + +import javax.inject.Singleton; + +import com.sk89q.minecraft.util.commands.Command; +import com.sk89q.minecraft.util.commands.CommandContext; +import com.sk89q.minecraft.util.commands.CommandException; +import com.sk89q.minecraft.util.commands.CommandPermissions; +import org.bukkit.command.CommandSender; +import tc.oc.commons.core.commands.Commands; +import tc.oc.pgm.PGMTranslations; +import tc.oc.pgm.commands.CommandUtils; +import tc.oc.pgm.match.Competitor; +import tc.oc.pgm.match.Match; +import tc.oc.pgm.match.MatchPlayer; +import tc.oc.pgm.teams.TeamMatchModule; + +@Singleton +public class JoinCommands implements Commands { + @Command( + aliases = { "join" }, + desc = "Joins the current match", + usage = "[team] - defaults to random", + flags = "f", + min = 0, + max = -1 + ) + @CommandPermissions(JoinMatchModule.JOIN_PERMISSION) + public void join(CommandContext args, CommandSender sender) throws CommandException { + MatchPlayer player = CommandUtils.senderToMatchPlayer(sender); + Match match = player.getMatch(); + JoinMatchModule jmm = match.needMatchModule(JoinMatchModule.class); + TeamMatchModule tmm = match.getMatchModule(TeamMatchModule.class); + + boolean force = sender.hasPermission("pgm.join.force") && args.hasFlag('f'); + Competitor chosenParty = null; + + if(args.argsLength() > 0) { + if(args.getJoinedStrings(0).trim().toLowerCase().startsWith("obs")) { + observe(args, sender); + return; + } else if(tmm != null) { + // player wants to join a specific team + chosenParty = tmm.bestFuzzyMatch(args.getJoinedStrings(0)); + if(chosenParty == null) throw new CommandException(PGMTranslations.get().t("command.teamNotFound", sender)); + } + } + + jmm.requestJoin(player, force ? JoinMethod.FORCE : JoinMethod.USER, chosenParty); + } + + public static final String OBSERVE_COMMAND = "observe"; + + @Command( + aliases = { OBSERVE_COMMAND, "obs", "spectate" }, + desc = "Observe the current match", + min = 0, + max = 0 + ) + @CommandPermissions(JoinMatchModule.JOIN_PERMISSION) + public void observe(CommandContext args, CommandSender sender) throws CommandException { + final MatchPlayer player = CommandUtils.senderToMatchPlayer(sender); + player.getMatch().needMatchModule(JoinMatchModule.class).requestObserve(player); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/join/JoinConfiguration.java b/PGM/src/main/java/tc/oc/pgm/join/JoinConfiguration.java new file mode 100644 index 0000000..b42ad39 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/join/JoinConfiguration.java @@ -0,0 +1,46 @@ +package tc.oc.pgm.join; + +import javax.inject.Inject; + +import tc.oc.minecraft.api.configuration.Configuration; +import tc.oc.minecraft.api.configuration.ConfigurationSection; + +import static com.google.common.base.Preconditions.checkNotNull; + +public class JoinConfiguration { + + private final ConfigurationSection config; + + @Inject JoinConfiguration(Configuration root) { + this.config = checkNotNull(root.getSection("join")); + } + + public boolean priorityKick() { + return config.getBoolean("priority-kick", true); + } + + public boolean midMatch() { + return config.getBoolean("mid-match", true); + } + + public boolean commitPlayers() { + return config.getBoolean("commit-players", false); + } + + public boolean capacity() { + return config.getBoolean("capacity.enabled", false); + } + + public boolean overfill() { + return config.getBoolean("capacity.overfill", false); + } + + public double overfillRatio() { + return Math.max(1, config.getDouble("capacity.overfill-ratio", 1.25)); + } + + public int overfillFromMax(int max) { + return overfill() ? (int) (max * overfillRatio()) + : max; + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/join/JoinDenied.java b/PGM/src/main/java/tc/oc/pgm/join/JoinDenied.java new file mode 100644 index 0000000..9325bba --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/join/JoinDenied.java @@ -0,0 +1,94 @@ +package tc.oc.pgm.join; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +import com.google.common.collect.Iterables; +import net.md_5.bungee.api.chat.BaseComponent; +import net.md_5.bungee.api.chat.TranslatableComponent; + +import static com.google.common.base.Preconditions.checkNotNull; + +public class JoinDenied implements JoinResult { + + private final BaseComponent message; + private final boolean visible; + private final boolean error; + private final List extra = new ArrayList<>(); + + protected JoinDenied(boolean visible, boolean error, BaseComponent message) { + this.visible = visible; + this.error = error; + this.message = checkNotNull(message); + } + + public static JoinDenied translate(boolean visible, boolean error, String translate, Object... with) { + return new JoinDenied(visible, error, new TranslatableComponent(translate, with)); + } + + /** + * User would generally expect to be able to join right now, but they can't due to + * some exceptional condition. + * + * Example: match full + */ + public static JoinDenied unavailable(String translate, Object... with) { + return translate(true, false, translate, with); + } + + /** + * User cannot join right now, but they will be able to in the near future, + * so failure message should look like a friendly reminder rather than an error. + * + * Example: match finished + */ + public static JoinDenied friendly(String translate, Object... with) { + return translate(false, false, translate, with); + } + + /** + * Joining is completely off the table, or doesn't make any sense. + * + * Example: already joined, no join permissions + */ + public static JoinDenied error(String translate, Object... with) { + return translate(false, true, translate, with); + } + + public JoinDenied also(BaseComponent message) { + extra.add(message); + return this; + } + + public JoinDenied also(Iterable messages) { + Iterables.addAll(extra, messages); + return this; + } + + @Override + public Optional message() { + return Optional.of(message); + } + + @Override + public Collection extra() { + return extra; + } + + @Override + public boolean isError() { + return error; + } + + @Override + public boolean isVisible() { + return visible; + } + + @Override + public boolean isAllowed() { + return false; + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/join/JoinHandler.java b/PGM/src/main/java/tc/oc/pgm/join/JoinHandler.java new file mode 100644 index 0000000..43fb6b6 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/join/JoinHandler.java @@ -0,0 +1,46 @@ +package tc.oc.pgm.join; + +import javax.annotation.Nullable; + +import tc.oc.pgm.match.MatchPlayer; + +/** + * Something that is able to join the player to the match, or prevent them from doing so. + */ +public interface JoinHandler { + /** + * Without side-effects or output, test what would happen if the given player tried to join the match right now. + * + * @param joining The joining player + * @param request Object describing the way they want to join + * @return Result of the join request. If the implementor does not know how to handle the + * query, it can return null to delegate to whatever + * other handlers are available. Any other result will be the final result of the + * query, and no other handlers will be called. + */ + @Nullable JoinResult queryJoin(MatchPlayer joining, JoinRequest request); + + /** + * Try to join the given player to the match, or tell them why they can't. This handler does not have to + * handle the request if it doesn't know how, or doesn't care. Note that a handler is allowed to return a + * result from {@link #queryJoin} that it does not handle in {@link #join}, and vice-versa. + * + * @param joining The joining player + * @param request Object describing the way they want to join + * @param result A fresh result from {@link #queryJoin} that should be used + * @return True if this implementor "handled" the join, meaning either the player + * joined the match successfully, or received some feedback explaining why they + * didn't. Returning true prevents any other handlers from being called after + * this one. + */ + default boolean join(MatchPlayer joining, JoinRequest request, JoinResult result) { return false; } + + /** + * Try to join all of the given players simultaneously. This is called with + * all queued players when the match starts. This method will be called on + * all handlers, breaking if the queue becomes empty. Any players left in + * the queue will be joined through {@link #join}, and finally sent to obs + * if that fails. + */ + default void queuedJoin(QueuedParticipants queue) {} +} diff --git a/PGM/src/main/java/tc/oc/pgm/join/JoinMatchModule.java b/PGM/src/main/java/tc/oc/pgm/join/JoinMatchModule.java new file mode 100644 index 0000000..a55f263 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/join/JoinMatchModule.java @@ -0,0 +1,338 @@ +package tc.oc.pgm.join; + +import java.util.LinkedHashSet; +import java.util.Set; +import javax.annotation.Nullable; +import javax.inject.Inject; + +import com.google.common.eventbus.EventBus; +import net.md_5.bungee.api.ChatColor; +import net.md_5.bungee.api.chat.TranslatableComponent; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import tc.oc.api.bukkit.users.BukkitUserStore; +import tc.oc.api.bukkit.users.OnlinePlayers; +import tc.oc.api.docs.Arena; +import tc.oc.api.docs.Server; +import tc.oc.api.docs.Ticket; +import tc.oc.api.games.TicketStore; +import tc.oc.api.model.ModelDispatcher; +import tc.oc.api.model.ModelListener; +import tc.oc.commons.bukkit.event.PlayerServerChangeEvent; +import tc.oc.commons.bukkit.teleport.Teleporter; +import tc.oc.commons.bukkit.ticket.TicketBooth; +import tc.oc.commons.core.chat.Component; +import tc.oc.commons.core.commands.CommandBinder; +import tc.oc.commons.core.formatting.PeriodFormats; +import tc.oc.pgm.events.ListenerScope; +import tc.oc.pgm.events.MatchPlayerAddEvent; +import tc.oc.pgm.events.MatchPreCommitEvent; +import tc.oc.pgm.match.Competitor; +import tc.oc.pgm.match.MatchModule; +import tc.oc.pgm.match.MatchPlayer; +import tc.oc.pgm.match.MatchScope; +import tc.oc.pgm.match.Party; +import tc.oc.pgm.match.inject.MatchModuleFixtureManifest; +import tc.oc.pgm.module.ModuleDescription; +import tc.oc.pgm.teams.TeamMatchModule; +import tc.oc.pgm.timelimit.TimeLimitMatchModule; + +@ModuleDescription(name = "Join") +@ListenerScope(MatchScope.LOADED) +public class JoinMatchModule extends MatchModule implements Listener, JoinHandler, ModelListener { + + public static class Manifest extends MatchModuleFixtureManifest { + @Override protected void configure() { + super.configure(); + + new CommandBinder(binder()) + .register(JoinCommands.class); + } + } + + public static final String JOIN_PERMISSION = "pgm.join"; + public static final String JOIN_FULL_PERMISSION = "pgm.join.full"; + public static final String PRIORITY_KICK_PERMISSION = JOIN_FULL_PERMISSION; + public static final String JOIN_OBSERVERS_PERMISSION = "pgm.join.choose.observing"; + + @Inject private JoinConfiguration config; + @Inject private QueuedParticipants queuedParticipants; + @Inject private Server localServer; + @Inject private BukkitUserStore userStore; + @Inject private OnlinePlayers onlinePlayers; + @Inject private EventBus eventBus; + @Inject private Teleporter teleporter; + @Inject private TicketStore tickets; + @Inject private TicketBooth ticketBooth; + @Inject private ModelDispatcher modelDispatcher; + + private final Set handlers = new LinkedHashSet<>(); + + @Override + public void load() { + super.load(); + eventBus.register(this); + getMatch().addParty(queuedParticipants); + ticketBooth.setPlayHandler(playHandler); + modelDispatcher.subscribe(this); + } + + @Override + public void unload() { + modelDispatcher.unsubscribe(this); + ticketBooth.removePlayHandler(playHandler); + eventBus.unregister(this); + super.unload(); + } + + public void registerHandler(JoinHandler handler) { + handlers.add(handler); + } + + public boolean canJoinFull(MatchPlayer joining) { + return !config.capacity() || (config.overfill() && joining.getBukkit().hasPermission(JOIN_FULL_PERMISSION)); + } + + public boolean canPriorityKick(MatchPlayer joining) { + return config.priorityKick() && joining.getBukkit().hasPermission(PRIORITY_KICK_PERMISSION) && !getMatch().hasStarted(); + } + + public boolean canJoinMid() { + return config.midMatch(); + } + + public boolean isRemoteJoin() { + return localServer.game_id() != null; + } + + @Override + public JoinResult queryJoin(MatchPlayer joining, JoinRequest request) { + // Player does not have permission to voluntarily join + if(!joining.getBukkit().hasPermission(JOIN_PERMISSION)) { + return JoinDenied.error("command.gameplay.join.joinDenied"); + } + + // If mid-match join is disabled, player cannot join for the first time after the match has started + if(!canJoinMid() && getMatch().isCommitted() && !getMatch().hasEverParticipated(joining.getPlayerId())) { + return JoinDenied.friendly("command.gameplay.join.matchStarted"); + } + + if(getMatch().isFinished()) { + // This message should NOT look like an error, because remotely joining players will see it often. + return JoinDenied.friendly("command.gameplay.join.matchFinished"); + } + + JoinResult best = new JoinDenied(false, true, new TranslatableComponent("command.gameplay.join.notSupported")) { + @Override public boolean isFallback() { return true; } + }; + + for(JoinHandler handler : handlers) { + final JoinResult result = handler.queryJoin(joining, request); + if(result != null && result.compareTo(best) < 0) best = result; + } + + return best; + } + + @Override + public boolean join(MatchPlayer joining, JoinRequest request, JoinResult result) { + result.output().forEach(joining::sendMessage); + + if(result instanceof JoinQueued) { + queueToJoin(joining); + return true; + } + + if(!result.isAllowed()) return true; + + for(JoinHandler handler : handlers) { + if(handler.join(joining, request, result)) return true; + } + + return false; + } + + public boolean requestJoin(MatchPlayer joining, JoinRequest request) { + final Player joiner = joining.getBukkit(); + if(isRemoteJoin() && request.method() != JoinMethod.REMOTE && !isLocalParticipant(joining)) { + ticketBooth.playLocalGame(joiner); + return true; + } else { + final Arena arena = ticketBooth.localArena(); + if(arena == null || !arena.equals(ticketBooth.currentArena(joiner))) { + ticketBooth.leaveGame(joiner, false); + } + return join(joining, request, queryJoin(joining, request)); + } + } + + public boolean requestJoin(MatchPlayer joining, JoinMethod method, @Nullable Competitor competitor) { + return requestJoin(joining, new JoinRequest(method, competitor)); + } + + public boolean requestJoin(MatchPlayer joining, JoinMethod method) { + return requestJoin(joining, method, null); + } + + public boolean observe(MatchPlayer leaving) { + final Party observers = getMatch().getDefaultParty(); + leaving.sendMessage(new TranslatableComponent("team.join", observers.getComponentName())); + return getMatch().setPlayerParty(leaving, observers); + } + + public boolean requestObserve(MatchPlayer leaving) { + if(cancelQueuedJoin(leaving)) return true; + + if(leaving.isObservingType()) { + leaving.sendWarning(new TranslatableComponent("command.gameplay.leave.alreadyOnObservers"), false); + return false; + } + + if(isRemoteJoin()) { + ticketBooth.leaveGame(leaving.getBukkit(), false); + } + + if(!leaving.getBukkit().hasPermission(JOIN_OBSERVERS_PERMISSION)) { + leaving.sendWarning(new TranslatableComponent("command.gameplay.leave.leaveDenied"), false); + return false; + } + + if(config.commitPlayers() && leaving.isCommitted()) { + leaving.sendWarning(new TranslatableComponent("command.gameplay.leave.leaveDenied"), false); + return false; + } + + return observe(leaving); + } + + public QueuedParticipants getQueuedParticipants() { + return queuedParticipants; + } + + public boolean isQueuedToJoin(MatchPlayer joining) { + return joining.inParty(queuedParticipants); + } + + public boolean queueToJoin(MatchPlayer joining) { + boolean joined = getMatch().setPlayerParty(joining, queuedParticipants); + if(joined) { + joining.sendMessage(new TranslatableComponent("ffa.join")); + } + + joining.sendMessage(new Component(new TranslatableComponent("team.join.deferred.request"), ChatColor.YELLOW)); // Always show this message + + if(getMatch().hasMatchModule(TeamMatchModule.class)) { + // If they are joining a team, show them a scary warning about leaving the match + joining.sendMessage( + new Component(new TranslatableComponent( + "team.join.forfeitWarning", + new Component(new TranslatableComponent("team.join.forfeitWarning.emphasis.warning"), ChatColor.RED), + new Component(new TranslatableComponent("team.join.forfeitWarning.emphasis.playUntilTheEnd"), ChatColor.RED), + new Component(new TranslatableComponent("team.join.forfeitWarning.emphasis.doubleLoss"), ChatColor.RED), + new Component(new TranslatableComponent("team.join.forfeitWarning.emphasis.suspension"), ChatColor.RED) + ), ChatColor.DARK_RED) + ); + + TimeLimitMatchModule tlmm = getMatch().getMatchModule(TimeLimitMatchModule.class); + if(tlmm != null && tlmm.getTimeLimit() != null) { + joining.sendMessage(new Component(new TranslatableComponent( + "team.join.forfeitWarning.timeLimit", + new Component(PeriodFormats.briefNaturalPrecise(tlmm.getTimeLimit().getDuration()), ChatColor.AQUA), + new Component("/" + JoinCommands.OBSERVE_COMMAND, ChatColor.GOLD) + ), ChatColor.DARK_RED, ChatColor.BOLD)); + } else { + joining.sendMessage(new Component(new TranslatableComponent( + "team.join.forfeitWarning.noTimeLimit", + new Component("/" + JoinCommands.OBSERVE_COMMAND, ChatColor.GOLD) + ), ChatColor.DARK_RED, ChatColor.BOLD)); + } + } + + return joined; + } + + public boolean cancelQueuedJoin(MatchPlayer joining) { + if(!isQueuedToJoin(joining)) return false; + if(getMatch().setPlayerParty(joining, getMatch().getDefaultParty())) { + joining.sendMessage(new Component(new TranslatableComponent("team.join.deferred.cancel"), ChatColor.YELLOW)); + return true; + } else { + return false; + } + } + + @Override + public void queuedJoin(QueuedParticipants queue) { + // Give all handlers a chance to bulk join + for(JoinHandler handler : handlers) { + if(queue.getPlayers().isEmpty()) break; + handler.queuedJoin(queue); + } + + // Send any leftover players to obs + for(MatchPlayer joining : queue.getOrderedPlayers()) { + getMatch().setPlayerParty(joining, getMatch().getDefaultParty()); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onMatchCommit(MatchPreCommitEvent event) { + queuedJoin(queuedParticipants); + } + + @EventHandler + public void onServerChange(PlayerServerChangeEvent event) { + MatchPlayer player = getMatch().getPlayer(event.getPlayer()); + if(config.commitPlayers() && player != null && player.isCommitted() && !getMatch().isFinished()) { + event.setCancelled(true, new TranslatableComponent("engagement.committed")); + } + } + + private boolean isLocalParticipant(MatchPlayer player) { + return isLocalParticipant(tickets.tryUser(player.getPlayerId())); + } + + private boolean isLocalParticipant(@Nullable Ticket ticket) { + return ticket != null && localServer._id().equals(ticket.server_id()); + } + + @EventHandler + public void onLogin(MatchPlayerAddEvent event) { + if(config.commitPlayers() && getMatch().isCommitted() && !getMatch().isFinished()) { + final Competitor competitor = getMatch().getLastCompetitor(event.getPlayerId()); + if(competitor != null) { + // Committed player is reconnecting + getMatch().setPlayerParty(event.getPlayer(), competitor); + return; + } + } + + if(isRemoteJoin() && isLocalParticipant(event.getPlayer())) { + // Player has a remote ticket to play on this server + requestJoin(event.getPlayer(), JoinMethod.REMOTE); + } + } + + @HandleModel + public void ticketUpdated(@Nullable Ticket before, @Nullable Ticket after, Ticket latest) { + if(isRemoteJoin()) match.player(latest.user()).ifPresent(player -> { + final boolean isPlaying = isLocalParticipant(after); + if(!player.isParticipatingType() && isPlaying) { + requestJoin(player, JoinMethod.REMOTE); + } else if(player.isParticipatingType() && !isPlaying) { + observe(player); + } + }); + }; + + private final TicketBooth.PlayHandler playHandler = player -> { + final MatchPlayer mp = match.getPlayer(player); + if(mp != null) { + requestJoin(mp, JoinMethod.USER); + return true; + } + return false; + }; +} diff --git a/PGM/src/main/java/tc/oc/pgm/join/JoinMethod.java b/PGM/src/main/java/tc/oc/pgm/join/JoinMethod.java new file mode 100644 index 0000000..84ceb1b --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/join/JoinMethod.java @@ -0,0 +1,8 @@ +package tc.oc.pgm.join; + +public enum JoinMethod { + USER, // Normal player joined in the normal way + FORCE, // Forced by a staff member + REMOTE, // Joined remotely by the API + PRIORITY_KICK // Rejoin after being priority kicked +} diff --git a/PGM/src/main/java/tc/oc/pgm/join/JoinQueued.java b/PGM/src/main/java/tc/oc/pgm/join/JoinQueued.java new file mode 100644 index 0000000..20cb852 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/join/JoinQueued.java @@ -0,0 +1,8 @@ +package tc.oc.pgm.join; + +public class JoinQueued implements JoinResult { + @Override + public boolean isAllowed() { + return true; + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/join/JoinRequest.java b/PGM/src/main/java/tc/oc/pgm/join/JoinRequest.java new file mode 100644 index 0000000..4a9b54a --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/join/JoinRequest.java @@ -0,0 +1,39 @@ +package tc.oc.pgm.join; + +import java.util.Optional; +import javax.annotation.Nullable; + +import tc.oc.pgm.match.Competitor; + +import static com.google.common.base.Preconditions.checkNotNull; + +public class JoinRequest { + + private final Optional competitor; + private final JoinMethod method; + + public JoinRequest(JoinMethod method) { + this(method, null); + } + + public JoinRequest(JoinMethod method, @Nullable Competitor competitor) { + this.competitor = Optional.ofNullable(competitor); + this.method = checkNotNull(method); + } + + public static JoinRequest user(@Nullable Competitor competitor) { + return new JoinRequest(JoinMethod.USER, competitor); + } + + public static JoinRequest user() { + return user(null); + } + + public JoinMethod method() { + return method; + } + + public Optional competitor() { + return competitor; + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/join/JoinResult.java b/PGM/src/main/java/tc/oc/pgm/join/JoinResult.java new file mode 100644 index 0000000..d61c352 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/join/JoinResult.java @@ -0,0 +1,89 @@ +package tc.oc.pgm.join; + +import java.util.Collection; +import java.util.Collections; +import java.util.Optional; + +import com.google.common.collect.ComparisonChain; +import com.google.common.collect.ImmutableList; +import net.md_5.bungee.api.ChatColor; +import net.md_5.bungee.api.chat.BaseComponent; +import tc.oc.commons.bukkit.chat.WarningComponent; +import tc.oc.commons.core.chat.Component; +import tc.oc.pgm.match.Competitor; + +public interface JoinResult extends Comparable { + + /** + * Did the join succeed? If this is true, {@link #competitor()} should be present. + */ + boolean isAllowed(); + + /** + * The {@link Competitor} that was joined. + */ + default Optional competitor() { return Optional.empty(); } + + /** + * Message to display to the joining player (unformatted) + */ + default Optional message() { return Optional.empty(); } + + /** + * Display the message as an error + */ + default boolean isError() { return false; } + + /** + * The default result + */ + default boolean isFallback() { return false; } + + /** + * Extra messages to display after the primary one, fully formatted, each on a new line + */ + default Collection extra() { return Collections.emptySet(); } + + /** + * Include this join as an option in the UI (but disable it if the join didn't succeed) + */ + default boolean isVisible() { return false; } + + /** + * Player is rejoining a team they were previously on + */ + default boolean isRejoin() { return false; } + + /** + * Will another player be kicked as a result of this join? + */ + default boolean priorityKickRequired() { return false; } + + /** + * Lines of output that should be sent to the player + */ + default Collection output() { + final ImmutableList.Builder lines = ImmutableList.builder(); + if(message().isPresent()) { + BaseComponent message = message().get(); + if(isError()) { + message = new WarningComponent(message); + } else { + message = new Component(message, ChatColor.AQUA); + } + lines.add(message); + } + lines.addAll(extra()); + return lines.build(); + } + + @Override + default int compareTo(JoinResult that) { + return ComparisonChain.start() + .compareFalseFirst(this.isFallback(), that.isFallback()) // Anything is better than this + .compareFalseFirst(this.isAllowed(), that.isAllowed()) // Deny overrides allow + .compareFalseFirst(this.priorityKickRequired(), that.priorityKickRequired()) // Avoid kick if possible + .compareTrueFirst(this.isRejoin(), that.isRejoin()) // Rejoin is preferred + .result(); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/join/QueuedParticipants.java b/PGM/src/main/java/tc/oc/pgm/join/QueuedParticipants.java new file mode 100644 index 0000000..e5f1a54 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/join/QueuedParticipants.java @@ -0,0 +1,78 @@ +package tc.oc.pgm.join; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import javax.inject.Inject; + +import net.md_5.bungee.api.ChatColor; +import tc.oc.pgm.match.Match; +import tc.oc.pgm.match.MatchPlayer; +import tc.oc.pgm.match.inject.MatchScoped; +import tc.oc.pgm.match.ObservingParty; + +/** + * Observing party that holds players who have requested to join before + * match start, when the server is configured to defer joins. + * After the match starts, this party is empty. + */ +@MatchScoped +public class QueuedParticipants extends ObservingParty { + + private final JoinConfiguration config; + private List shuffledPlayers; + + @Inject QueuedParticipants(Match match, JoinConfiguration config) { + super(match); + this.config = config; + } + + private void invalidateShuffle() { + shuffledPlayers = null; + } + + @Override + public boolean addPlayerInternal(MatchPlayer player) { + if(super.addPlayerInternal(player)) { + invalidateShuffle(); + return true; + } + return false; + } + + @Override + public boolean removePlayerInternal(MatchPlayer player) { + if(super.removePlayerInternal(player)) { + invalidateShuffle(); + return true; + } + return false; + } + + public List getOrderedPlayers() { + if(shuffledPlayers == null) { + shuffledPlayers = new ArrayList<>(getPlayers()); + Collections.shuffle(shuffledPlayers); + + if(config.priorityKick()) { + // If priority kicking is enabled, might as well join the high + // priority players first so nobody actually gets kicked. + final JoinMatchModule jmm = match.needMatchModule(JoinMatchModule.class); + Collections.sort(shuffledPlayers, (a, b) -> + Boolean.compare(jmm.canPriorityKick(b), jmm.canPriorityKick(a)) + ); + } + } + return shuffledPlayers; + } + + @Override + public String getDefaultName() { + return "Participants"; + } + + @Override + public ChatColor getColor() { + return ChatColor.YELLOW; + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/killreward/KillReward.java b/PGM/src/main/java/tc/oc/pgm/killreward/KillReward.java new file mode 100644 index 0000000..788621d --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/killreward/KillReward.java @@ -0,0 +1,18 @@ +package tc.oc.pgm.killreward; + +import com.google.common.collect.ImmutableList; +import org.bukkit.inventory.ItemStack; +import tc.oc.pgm.filters.Filter; +import tc.oc.pgm.kits.Kit; + +public class KillReward { + public final ImmutableList items; + public final Filter filter; + public final Kit kit; + + public KillReward(ImmutableList items, Filter filter, Kit kit) { + this.items = items; + this.filter = filter; + this.kit = kit; + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/killreward/KillRewardMatchModule.java b/PGM/src/main/java/tc/oc/pgm/killreward/KillRewardMatchModule.java new file mode 100644 index 0000000..4832d13 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/killreward/KillRewardMatchModule.java @@ -0,0 +1,90 @@ +package tc.oc.pgm.killreward; + +import java.util.Collection; +import java.util.List; +import javax.annotation.Nullable; + +import com.google.common.base.Predicate; +import com.google.common.collect.ArrayListMultimap; +import com.google.common.collect.Collections2; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Multimap; +import org.bukkit.event.Event; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import tc.oc.pgm.events.ListenerScope; +import tc.oc.pgm.events.MatchPlayerDeathEvent; +import tc.oc.pgm.events.PlayerPartyChangeEvent; +import tc.oc.pgm.filters.query.DamageQuery; +import tc.oc.pgm.kits.ItemKitApplicator; +import tc.oc.pgm.match.Match; +import tc.oc.pgm.match.MatchModule; +import tc.oc.pgm.match.MatchPlayer; +import tc.oc.pgm.match.MatchScope; +import tc.oc.pgm.match.ParticipantState; +import tc.oc.pgm.tracker.damage.DamageInfo; + +@ListenerScope(MatchScope.RUNNING) +public class KillRewardMatchModule extends MatchModule implements Listener { + protected final ImmutableList killRewards; + protected final Multimap deadPlayerRewards = ArrayListMultimap.create(); + + public KillRewardMatchModule(Match match, List killRewards) { + super(match); + this.killRewards = ImmutableList.copyOf(killRewards); + } + + private Collection getRewards(@Nullable Event event, ParticipantState victim, DamageInfo damageInfo) { + final DamageQuery query = DamageQuery.attackerDefault(event, victim, damageInfo); + return Collections2.filter(killRewards, new Predicate() { + @Override + public boolean apply(KillReward killReward) { + return killReward.filter.query(query).isAllowed(); + } + }); + } + + private Collection getRewards(MatchPlayerDeathEvent event) { + return getRewards(event, event.getVictim().getParticipantState(), event.getDamageInfo()); + } + + private void giveRewards(MatchPlayer killer, Collection rewards) { + for(KillReward reward : rewards) { + // Apply kit first so it can not override reward items + reward.kit.apply(killer); + reward.items.forEach(stack -> ItemKitApplicator.fireEventAndTransfer(killer, stack)); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onDeath(MatchPlayerDeathEvent event) { + if(!event.isChallengeKill()) return; + MatchPlayer killer = event.getOnlineKiller(); + if(killer == null) return; + + Collection rewards = getRewards(event); + + if(killer.isDead()) { + // If a player earns a KW while dead, give it to them when they respawn. Rationale: If they click respawn + // fast enough, they will get the reward anyway, and we can't prevent it in that case, so we might as well + // just give it to them always. Also, if the KW is in itemkeep, they should definitely get it while dead, + // and this is a relatively simple way to handle that case. + deadPlayerRewards.putAll(killer, rewards); + } else { + giveRewards(killer, rewards); + } + } + + /** + * This is called from {@link tc.oc.pgm.spawns.SpawnMatchModule} so that rewards are given after kits + */ + public void giveDeadPlayerRewards(MatchPlayer attacker) { + giveRewards(attacker, deadPlayerRewards.removeAll(attacker)); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onPartyChange(PlayerPartyChangeEvent event) { + deadPlayerRewards.removeAll(event.getPlayer()); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/killreward/KillRewardModule.java b/PGM/src/main/java/tc/oc/pgm/killreward/KillRewardModule.java new file mode 100644 index 0000000..9f9c251 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/killreward/KillRewardModule.java @@ -0,0 +1,71 @@ +package tc.oc.pgm.killreward; + +import java.util.List; +import java.util.Optional; +import java.util.logging.Logger; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import org.bukkit.inventory.ItemStack; +import org.jdom2.Document; +import org.jdom2.Element; +import tc.oc.pgm.filters.Filter; +import tc.oc.pgm.filters.matcher.StaticFilter; +import tc.oc.pgm.filters.parser.FilterParser; +import tc.oc.pgm.itemmeta.ItemModifyModule; +import tc.oc.pgm.kits.ItemParser; +import tc.oc.pgm.kits.Kit; +import tc.oc.pgm.kits.KitNode; +import tc.oc.pgm.kits.KitParser; +import tc.oc.pgm.map.MapModule; +import tc.oc.pgm.map.MapModuleContext; +import tc.oc.pgm.match.Match; +import tc.oc.pgm.match.MatchModuleFactory; +import tc.oc.pgm.module.ModuleDescription; +import tc.oc.pgm.utils.XMLUtils; +import tc.oc.pgm.xml.InvalidXMLException; + +@ModuleDescription(name="Kill Reward") +public class KillRewardModule implements MapModule, MatchModuleFactory { + protected final ImmutableList rewards; + + public KillRewardModule(List rewards) { + this.rewards = ImmutableList.copyOf(rewards); + } + + @Override + public KillRewardMatchModule createMatchModule(Match match) { + return new KillRewardMatchModule(match, this.rewards); + } + + // --------------------- + // ---- XML Parsing ---- + // --------------------- + + public static KillRewardModule parse(MapModuleContext context, Logger logger, Document doc) throws InvalidXMLException { + ImmutableList.Builder rewards = ImmutableList.builder(); + final ItemParser itemParser = context.needModule(ItemParser.class); + final Optional itemModifier = context.module(ItemModifyModule.class); + + // Must allow top-level children for legacy support + for(Element elKillReward : XMLUtils.flattenElements(doc.getRootElement(), ImmutableSet.of("kill-rewards"), ImmutableSet.of("kill-reward", "killreward"), 0)) { + ImmutableList.Builder items = ImmutableList.builder(); + for(Element itemEl : elKillReward.getChildren("item")) { + final ItemStack item = itemParser.parseItem(itemEl, false); + itemModifier.ifPresent(imm -> imm.applyRules(item)); + items.add(item.immutableCopy()); + } + Filter filter = context.needModule(FilterParser.class).property(elKillReward, "filter").optional(StaticFilter.ALLOW); + Kit kit = context.needModule(KitParser.class).property(elKillReward, "kit").optional(KitNode.EMPTY); + + rewards.add(new KillReward(items.build(), filter, kit)); + } + + ImmutableList list = rewards.build(); + if(list.isEmpty()) { + return null; + } else { + return new KillRewardModule(list); + } + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/kits/AttributeKit.java b/PGM/src/main/java/tc/oc/pgm/kits/AttributeKit.java new file mode 100644 index 0000000..9fe6dbf --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/kits/AttributeKit.java @@ -0,0 +1,38 @@ +package tc.oc.pgm.kits; + +import org.bukkit.attribute.Attribute; +import org.bukkit.attribute.AttributeModifier; +import tc.oc.commons.core.util.Pair; +import tc.oc.pgm.match.MatchPlayer; + +public class AttributeKit extends Kit.Impl { + + @Inspect private final Attribute attribute; + @Inspect private final AttributeModifier modifier; + + public AttributeKit(Pair pair) { + this(pair.first, pair.second); + } + + public AttributeKit(Attribute attribute, AttributeModifier modifier) { + this.attribute = attribute; + this.modifier = modifier; + } + + @Override + public boolean isRemovable() { + return true; + } + + @Override + public void apply(MatchPlayer player, boolean force, ItemKitApplicator items) { + player.facet(AttributePlayerFacet.class) + .addModifier(attribute, modifier); + } + + @Override + public void remove(MatchPlayer player) { + player.facet(AttributePlayerFacet.class) + .removeModifier(attribute, modifier); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/kits/AttributePlayerFacet.java b/PGM/src/main/java/tc/oc/pgm/kits/AttributePlayerFacet.java new file mode 100644 index 0000000..9aa7792 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/kits/AttributePlayerFacet.java @@ -0,0 +1,58 @@ +package tc.oc.pgm.kits; + +import javax.inject.Inject; + +import com.google.common.collect.HashMultimap; +import com.google.common.collect.SetMultimap; +import org.bukkit.attribute.Attribute; +import org.bukkit.attribute.AttributeInstance; +import org.bukkit.attribute.AttributeModifier; +import org.bukkit.entity.Player; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import tc.oc.commons.bukkit.event.targeted.TargetedEventHandler; +import tc.oc.commons.core.util.MultimapHelper; +import tc.oc.pgm.events.PlayerResetEvent; +import tc.oc.pgm.match.MatchPlayerFacet; + +public class AttributePlayerFacet implements MatchPlayerFacet, Listener { + + private final Player player; + private final SetMultimap modifiers = HashMultimap.create(); + + @Inject AttributePlayerFacet(Player player) { + this.player = player; + } + + private boolean addModifier0(Attribute attribute, AttributeModifier modifier) { + final AttributeInstance attributeInstance = player.getAttribute(attribute); + if(attributeInstance != null && !attributeInstance.getModifiers().contains(modifier)) { + attributeInstance.addModifier(modifier); + return true; + } + return false; + } + + public boolean addModifier(Attribute attribute, AttributeModifier modifier) { + return modifiers.put(attribute, modifier) && addModifier0(attribute, modifier); + } + + private boolean removeModifier0(Attribute attribute, AttributeModifier modifier) { + AttributeInstance attributeValue = player.getAttribute(attribute); + if(attributeValue != null && attributeValue.getModifiers().contains(modifier)) { + attributeValue.removeModifier(modifier); + return true; + } + return false; + } + + public boolean removeModifier(Attribute attribute, AttributeModifier modifier) { + return modifiers.remove(attribute, modifier) && removeModifier0(attribute, modifier); + } + + @TargetedEventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onPlayerReset(final PlayerResetEvent event) { + MultimapHelper.forEach(modifiers, this::removeModifier0); + modifiers.clear(); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/kits/ClearItemsKit.java b/PGM/src/main/java/tc/oc/pgm/kits/ClearItemsKit.java new file mode 100644 index 0000000..1a8b06f --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/kits/ClearItemsKit.java @@ -0,0 +1,25 @@ +package tc.oc.pgm.kits; + +import java.util.stream.Stream; + +import org.bukkit.inventory.ItemStack; +import tc.oc.commons.bukkit.inventory.Slot; +import tc.oc.commons.core.util.Streams; + +/** + * Exists only for backward support of + */ +@Deprecated +public class ClearItemsKit extends ClearKitBase { + @Override + protected Stream slots() { + return Streams.concat(Slot.Storage.storage(), + Stream.of(Slot.OffHand.offHand()), + Stream.of(Slot.Cursor.cursor())); + } + + @Override + protected boolean filter(ItemStack item) { + return true; + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/kits/ClearKit.java b/PGM/src/main/java/tc/oc/pgm/kits/ClearKit.java new file mode 100644 index 0000000..c822dc9 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/kits/ClearKit.java @@ -0,0 +1,33 @@ +package tc.oc.pgm.kits; + +import java.util.Optional; +import java.util.stream.Stream; + +import org.bukkit.inventory.ItemStack; +import tc.oc.commons.bukkit.inventory.Slot; +import tc.oc.pgm.utils.MaterialMatcher; + +/** + * Clear items from the player's inventory + */ +public class ClearKit extends ClearKitBase { + + private final @Inspect Optional slot; + private final @Inspect MaterialMatcher materials; + + public ClearKit(Optional slot, MaterialMatcher materials) { + this.slot = slot; + this.materials = materials; + } + + @Override + protected Stream slots() { + return slot.map(Stream::of) + .orElseGet(Slot.Player::player); + } + + @Override + protected boolean filter(ItemStack item) { + return materials.matches(item); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/kits/ClearKitBase.java b/PGM/src/main/java/tc/oc/pgm/kits/ClearKitBase.java new file mode 100644 index 0000000..1b2d00c --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/kits/ClearKitBase.java @@ -0,0 +1,26 @@ +package tc.oc.pgm.kits; + +import java.util.stream.Stream; + +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +import tc.oc.commons.bukkit.inventory.Slot; +import tc.oc.pgm.match.MatchPlayer; + +public abstract class ClearKitBase extends Kit.Impl { + + protected abstract Stream slots(); + + protected abstract boolean filter(ItemStack item); + + @Override + public void apply(MatchPlayer player, boolean force, ItemKitApplicator items) { + final PlayerInventory inv = player.getInventory(); + slots().forEach(slot -> { + final ItemStack item = slot.getItem(inv); + if(item != null && filter(item)) { + slot.putItem(inv, null); + } + }); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/kits/DelayedKit.java b/PGM/src/main/java/tc/oc/pgm/kits/DelayedKit.java new file mode 100644 index 0000000..a0097a6 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/kits/DelayedKit.java @@ -0,0 +1,20 @@ +package tc.oc.pgm.kits; + +import tc.oc.pgm.match.MatchPlayer; +import tc.oc.pgm.match.MatchScope; +import tc.oc.pgm.match.Party; + +public abstract class DelayedKit implements Kit { + + public abstract void applyDelayed(MatchPlayer player, boolean force); + + @Override + public void apply(MatchPlayer player, boolean force, ItemKitApplicator items) { + Party party = player.getParty(); + player.getMatch().getScheduler(MatchScope.RUNNING).createDelayedTask(1L, () -> { + if (player.isOnline() && player.getParty().equals(party)) + applyDelayed(player, force); + }); + } + +} diff --git a/PGM/src/main/java/tc/oc/pgm/kits/EliminateKit.java b/PGM/src/main/java/tc/oc/pgm/kits/EliminateKit.java new file mode 100644 index 0000000..5f699d6 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/kits/EliminateKit.java @@ -0,0 +1,13 @@ +package tc.oc.pgm.kits; + +import tc.oc.pgm.join.JoinMatchModule; +import tc.oc.pgm.match.MatchPlayer; + +public class EliminateKit extends DelayedKit { + + @Override + public void applyDelayed(MatchPlayer player, boolean force) { + player.getMatch().needMatchModule(JoinMatchModule.class).observe(player); + } + +} diff --git a/PGM/src/main/java/tc/oc/pgm/kits/FlyKit.java b/PGM/src/main/java/tc/oc/pgm/kits/FlyKit.java new file mode 100644 index 0000000..b8b250f --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/kits/FlyKit.java @@ -0,0 +1,52 @@ +package tc.oc.pgm.kits; + +import com.google.api.client.repackaged.com.google.common.base.Preconditions; +import tc.oc.pgm.match.MatchPlayer; + +import javax.annotation.Nullable; + +public class FlyKit extends Kit.Impl { + public static final float MIN = 0, MAX = 10; + public static final float BASE_INTERNAL_SPEED = 0.1f; + + protected final boolean allowFlight; + protected final @Nullable Boolean flying; + protected final float flySpeedMultiplier; + + + public FlyKit(boolean allowFlight, @Nullable Boolean flying, float flySpeedMultiplier) { + Preconditions.checkArgument(flying == null || !(flying && !allowFlight), "Flying cannot be true if allow-flight is false"); + + this.allowFlight = allowFlight; + this.flying = flying; + this.flySpeedMultiplier = flySpeedMultiplier; + } + + @Override + public void apply(MatchPlayer player, boolean force, ItemKitApplicator items) { + player.getBukkit().setAllowFlight(this.allowFlight); + if(this.flying != null) { + player.getBukkit().setFlying(this.flying); + } + + player.getBukkit().setFlySpeed(BASE_INTERNAL_SPEED * flySpeedMultiplier); + } + + @Override + public boolean isRemovable() { + return true; + } + + @Override + public void remove(MatchPlayer player) { + if(allowFlight) { + player.getBukkit().setAllowFlight(false); + } + if(flying != null) { + player.getBukkit().setFlying(!flying); + } + if(flySpeedMultiplier != 1) { + player.getBukkit().setFlySpeed(BASE_INTERNAL_SPEED); + } + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/kits/ForceKit.java b/PGM/src/main/java/tc/oc/pgm/kits/ForceKit.java new file mode 100644 index 0000000..cf6d20b --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/kits/ForceKit.java @@ -0,0 +1,31 @@ +package tc.oc.pgm.kits; + +import org.bukkit.util.Vector; +import tc.oc.pgm.match.MatchPlayer; +import tc.oc.pgm.physics.AccelerationPlayerFacet; +import tc.oc.pgm.physics.PlayerForce; +import tc.oc.pgm.physics.RelativeFlags; + +public class ForceKit extends Kit.Impl { + + private final PlayerForce playerForce; + + public ForceKit(Vector acceleration, RelativeFlags relative) { + this.playerForce = new PlayerForce(acceleration, relative); + } + + @Override + public boolean isRemovable() { + return true; + } + + @Override + public void apply(MatchPlayer player, boolean force, ItemKitApplicator items) { + player.facet(AccelerationPlayerFacet.class).addForce(playerForce); + } + + @Override + public void remove(MatchPlayer player) { + player.facet(AccelerationPlayerFacet.class).removeForce(playerForce); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/kits/FreeItemKit.java b/PGM/src/main/java/tc/oc/pgm/kits/FreeItemKit.java new file mode 100644 index 0000000..85f5456 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/kits/FreeItemKit.java @@ -0,0 +1,23 @@ +package tc.oc.pgm.kits; + +import org.bukkit.inventory.ItemStack; +import tc.oc.pgm.match.MatchPlayer; + +public class FreeItemKit extends BaseItemKit { + + protected final ItemStack item; + + public FreeItemKit(ItemStack item) { + this.item = item; + } + + @Override + public ItemStack item() { + return item; + } + + @Override + public void apply(MatchPlayer player, boolean force, ItemKitApplicator items) { + items.add(item); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/kits/GameModeKit.java b/PGM/src/main/java/tc/oc/pgm/kits/GameModeKit.java new file mode 100644 index 0000000..2b7e39e --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/kits/GameModeKit.java @@ -0,0 +1,18 @@ +package tc.oc.pgm.kits; + +import org.bukkit.GameMode; +import tc.oc.pgm.match.MatchPlayer; + +public class GameModeKit extends Kit.Impl { + + private final GameMode gameMode; + + public GameModeKit(GameMode mode) { + gameMode = mode; + } + + @Override + public void apply(MatchPlayer player, boolean force, ItemKitApplicator items) { + player.getBukkit().setGameMode(gameMode); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/kits/GlobalItemParser.java b/PGM/src/main/java/tc/oc/pgm/kits/GlobalItemParser.java new file mode 100644 index 0000000..99fdac0 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/kits/GlobalItemParser.java @@ -0,0 +1,401 @@ +package tc.oc.pgm.kits; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.regex.Pattern; +import javax.annotation.Nullable; +import javax.inject.Inject; + +import com.google.common.base.Splitter; +import com.google.common.collect.HashMultimap; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Lists; +import com.google.common.collect.Maps; +import com.google.common.collect.SetMultimap; +import org.bukkit.Color; +import org.bukkit.Material; +import org.bukkit.attribute.Attribute; +import org.bukkit.attribute.AttributeModifier; +import org.bukkit.attribute.ItemAttributeModifier; +import org.bukkit.enchantments.Enchantment; +import org.bukkit.inventory.ItemFlag; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.BookMeta; +import org.bukkit.inventory.meta.EnchantmentStorageMeta; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.inventory.meta.LeatherArmorMeta; +import org.bukkit.inventory.meta.PotionMeta; +import org.bukkit.inventory.meta.SkullMeta; +import org.bukkit.potion.Potion; +import org.bukkit.potion.PotionData; +import org.bukkit.potion.PotionEffect; +import org.bukkit.potion.PotionEffectType; +import org.jdom2.Element; +import tc.oc.commons.bukkit.util.BukkitUtils; +import tc.oc.commons.core.formatting.StringUtils; +import tc.oc.commons.core.util.Pair; +import tc.oc.pgm.kits.tag.Grenade; +import tc.oc.pgm.kits.tag.ItemTags; +import tc.oc.pgm.utils.XMLUtils; +import tc.oc.pgm.xml.InvalidXMLException; +import tc.oc.pgm.xml.Node; +import tc.oc.pgm.xml.parser.ElementParser; +import tc.oc.pgm.xml.parser.Parser; + +import static tc.oc.commons.core.exception.LambdaExceptionUtils.rethrowFunction; + +/** + * Item parser with no MapScoped dependencies, so it can be used outside of any specific map. + * + * May be missing some features that require a map. + */ +public class GlobalItemParser implements ElementParser { + + private final Parser materialParser; + + @Inject protected GlobalItemParser(Parser materialParser) { + this.materialParser = materialParser; + } + + @Override + public @Nullable ItemStack parseElement(@Nullable Element element) throws InvalidXMLException { + return parseItem(element, true); + } + + public ItemStack parseRequiredItem(Element parent) throws InvalidXMLException { + final Element el = XMLUtils.getRequiredUniqueChild(parent); + switch(el.getName()) { + case "item": return parseItem(el, false); + case "head": return parseItem(el, Material.SKULL_ITEM, (short) 3); + case "book": return parseItem(el, Material.WRITTEN_BOOK); + } + throw new InvalidXMLException("Item expected", el); + } + + public ItemStack parseBook(Element el) throws InvalidXMLException { + return parseItem(el, Material.WRITTEN_BOOK); + } + + public ItemStack parseHead(Element el) throws InvalidXMLException { + return parseItem(el, Material.SKULL_ITEM, (short) 3); + } + + public @Nullable ItemStack parseItem(@Nullable Element el, boolean allowAir) throws InvalidXMLException { + if (el == null) return null; + + final Node materialNode = Optional.ofNullable(el.getAttribute("material")) + .map(Node::of) + .orElseGet(() -> Node.of(el)); + final Material material = materialParser.parse(materialNode); + + if(material == Material.AIR && !allowAir) { + throw new InvalidXMLException("Material AIR is not allowed here", materialNode); + } + + return parseItem(el, material); + } + + public ItemStack parseItem(Element el, Material type) throws InvalidXMLException { + return parseItem(el, type, XMLUtils.parseNumber(el.getAttribute("damage"), Short.class, (short) 0)); + } + + public ItemStack parseItem(Element el, Material type, short damage) throws InvalidXMLException { + int amount = XMLUtils.parseNumber(el.getAttribute("amount"), Integer.class, 1); + + // If the item is a potion with non-zero damage, and there is + // no modern potion ID, decode the legacy damage value. + final Potion legacyPotion; + if(type == Material.POTION && damage > 0 && el.getAttribute("potion") == null) { + try { + legacyPotion = Potion.fromDamage(damage); + } catch(IllegalArgumentException e) { + throw new InvalidXMLException("Invalid legacy potion damage value " + damage + ": " + e.getMessage(), el, e); + } + + // If the legacy splash bit is set, convert to a splash potion + if(legacyPotion.isSplash()) { + type = Material.SPLASH_POTION; + legacyPotion.setSplash(false); + } + + // Potions always have damage 0 + damage = 0; + } else { + legacyPotion = null; + } + + ItemStack itemStack = new ItemStack(type, amount, damage); + if(itemStack.getType() != type) { + throw new InvalidXMLException("Invalid item/block", el); + } + + final ItemMeta meta = itemStack.getItemMeta(); + if(meta != null) { // This happens if the item is "air" + parseItemMeta(el, meta); + + // If we decoded a legacy potion, apply it now, but only if there are no custom effects. + // This emulates the old behavior of custom effects overriding default effects. + if(legacyPotion != null) { + final PotionMeta potionMeta = (PotionMeta) meta; + if(!potionMeta.hasCustomEffects()) { + potionMeta.setBasePotionData(new PotionData(legacyPotion.getType(), + legacyPotion.hasExtendedDuration(), + legacyPotion.getLevel() == 2)); + } + } + + itemStack.setItemMeta(meta); + } + + return itemStack; + } + + public void parseItemMeta(Element el, ItemMeta meta) throws InvalidXMLException { + parseEnchantments(el, "enchantment").forEach( + (enchantment, level) -> meta.addEnchant(enchantment, level, true) + ); + + if(meta instanceof EnchantmentStorageMeta) { + parseEnchantments(el, "stored-enchantment").forEach( + (enchantment, level) -> ((EnchantmentStorageMeta) meta).addStoredEnchant(enchantment, level, true) + ); + } + + if(meta instanceof PotionMeta) { + final PotionMeta potionMeta = (PotionMeta) meta; + + final Node potionAttr = Node.fromAttr(el, "potion"); + if(potionAttr != null) { + potionMeta.setPotionBrew(XMLUtils.parsePotion(potionAttr)); + } + + final List effects = parsePotionEffects(el); + + for(PotionEffect effect : potionMeta.getCustomEffects()) { + potionMeta.removeCustomEffect(effect.getType()); + } + + for(PotionEffect effect : effects) { + potionMeta.addCustomEffect(effect, false); + } + } + + for(Map.Entry entry : parseItemAttributeModifiers(el).entries()) { + meta.addAttributeModifier(entry.getKey(), entry.getValue()); + } + + String customName = el.getAttributeValue("name"); + if(customName != null) { + meta.setDisplayName(BukkitUtils.colorize(customName)); + } else if (XMLUtils.parseBoolean(el.getAttribute("grenade"), false)) { + meta.setDisplayName("Grenade"); + } + + if(meta instanceof LeatherArmorMeta) { + LeatherArmorMeta armorMeta = (LeatherArmorMeta) meta; + org.jdom2.Attribute attrColor = el.getAttribute("color"); + if(attrColor != null) { + String raw = attrColor.getValue(); + if(!raw.matches("[a-fA-F0-9]{6}")) { + throw new InvalidXMLException("Invalid color format", attrColor); + } + armorMeta.setColor(Color.fromRGB(Integer.parseInt(attrColor.getValue(), 16))); + } + } + + String loreText = el.getAttributeValue("lore"); + if(loreText != null) { + List lore = ImmutableList.copyOf(Splitter.on('|').split(BukkitUtils.colorize(loreText))); + meta.setLore(lore); + } + + for(ItemFlag flag : ItemFlag.values()) { + if(!XMLUtils.parseBoolean(Node.fromAttr(el, "show-" + itemFlagName(flag)), true)) { + meta.addItemFlags(flag); + } + } + + if(XMLUtils.parseBoolean(el.getAttribute("unbreakable"), false)) { + meta.setUnbreakable(true); + } + + Element elCanDestroy = el.getChild("can-destroy"); + if(elCanDestroy != null) { + meta.setCanDestroy(XMLUtils.parseMaterialMatcher(elCanDestroy).getMaterials()); + } + + Element elCanPlaceOn = el.getChild("can-place-on"); + if(elCanPlaceOn != null) { + meta.setCanPlaceOn(XMLUtils.parseMaterialMatcher(elCanPlaceOn).getMaterials()); + } + + if(meta instanceof SkullMeta) { + final Node skin = Node.fromChildOrAttr(el, "skin"); + if(skin != null) { + ((SkullMeta) meta).setOwner(XMLUtils.parseUsername(Node.fromChildOrAttr(el, "username")), + Node.childOrAttr(el, "uuid") + .map(rethrowFunction(XMLUtils::parseUuid)) + .orElseGet(UUID::randomUUID), + XMLUtils.parseUnsignedSkin(Node.fromRequiredChildOrAttr(el, "skin"))); + } + } + + if(meta instanceof BookMeta) { + final BookMeta book = (BookMeta) meta; + + Node.childOrAttr(el, "title").ifPresent( + node -> book.setTitle(BukkitUtils.colorize(node.getValue())) + ); + Node.childOrAttr(el, "author").ifPresent( + node -> book.setAuthor(BukkitUtils.colorize(node.getValue())) + ); + + Element elPages = el.getChild("pages"); + if(elPages != null) { + for(Element elPage : elPages.getChildren("page")) { + String text = elPage.getText(); + text = text.trim(); // Remove leading and trailing whitespace + text = Pattern.compile("^[ \\t]+", Pattern.MULTILINE).matcher(text).replaceAll(""); // Remove indentation on each line + text = Pattern.compile("^\\n", Pattern.MULTILINE).matcher(text).replaceAll(" \n"); // Add a space to blank lines, otherwise they vanish for unknown reasons + text = BukkitUtils.colorize(text); // Color codes + book.addPage(text); + } + } + } + + parseCustomNBT(el, meta); + } + + String itemFlagName(ItemFlag flag) { + switch(flag) { + case HIDE_ATTRIBUTES: return "attributes"; + case HIDE_ENCHANTS: return "enchantments"; + case HIDE_UNBREAKABLE: return "unbreakable"; + case HIDE_DESTROYS: return "can-destroy"; + case HIDE_PLACED_ON: return "can-place-on"; + case HIDE_POTION_EFFECTS: return "other"; + } + throw new IllegalStateException("Unknown item flag " + flag); + } + + public void parseCustomNBT(Element el, ItemMeta meta) throws InvalidXMLException { + if (XMLUtils.parseBoolean(el.getAttribute("grenade"), false)) { + Grenade.ITEM_TAG.set(meta, new Grenade( + XMLUtils.parseNumber(el.getAttribute("grenade-power"), Float.class, 1f), + XMLUtils.parseBoolean(el.getAttribute("grenade-fire"), false), + XMLUtils.parseBoolean(el.getAttribute("grenade-destroy"), true) + )); + } + + if(XMLUtils.parseBoolean(el.getAttribute("prevent-sharing"), false)) { + ItemTags.PREVENT_SHARING.set(meta, true); + } + + if(XMLUtils.parseBoolean(el.getAttribute("locked"), false)) { + ItemTags.LOCKED.set(meta, true); + } + } + + public Pair parseEnchantment(Element el) throws InvalidXMLException { + return Pair.create(XMLUtils.parseEnchantment(new Node(el)), + XMLUtils.parseNumber(Node.fromAttr(el, "level"), Integer.class, 1)); + } + + public Map parseEnchantments(Element el, String name) throws InvalidXMLException { + Map enchantments = Maps.newHashMap(); + + Node attr = Node.fromAttr(el, name, StringUtils.pluralize(name)); + if(attr != null) { + Iterable enchantmentTexts = Splitter.on(";").split(attr.getValue()); + for(String enchantmentText : enchantmentTexts) { + int level = 1; + List parts = Lists.newArrayList(Splitter.on(":").limit(2).split(enchantmentText)); + Enchantment enchant = XMLUtils.parseEnchantment(attr, parts.get(0)); + if(parts.size() > 1) { + level = XMLUtils.parseNumber(attr, parts.get(1), Integer.class); + } + enchantments.put(enchant, level); + } + } + + for(Element elEnchantment : el.getChildren(name)) { + Pair entry = parseEnchantment(elEnchantment); + enchantments.put(entry.first, entry.second); + } + + return enchantments; + } + + public SetMultimap parseAttributeModifiers(Element el) throws InvalidXMLException { + SetMultimap modifiers = HashMultimap.create(); + + Node attr = Node.fromAttr(el, "attribute", "attributes"); + if(attr != null) { + for(String modifierText : Splitter.on(";").split(attr.getValue())) { + Pair mod = XMLUtils.parseCompactAttributeModifier(attr, modifierText); + modifiers.put(mod.first, mod.second); + } + } + + for(Element elAttribute : el.getChildren("attribute")) { + Pair mod = XMLUtils.parseAttributeModifier(elAttribute); + modifiers.put(mod.first, mod.second); + } + + return modifiers; + } + + public SetMultimap parseItemAttributeModifiers(Element el) throws InvalidXMLException { + SetMultimap modifiers = HashMultimap.create(); + + Node attr = Node.fromAttr(el, "attribute", "attributes"); + if(attr != null) { + for(String modifierText : Splitter.on(";").split(attr.getValue())) { + Pair mod = XMLUtils.parseCompactAttributeModifier(attr, modifierText); + modifiers.put(mod.first, new ItemAttributeModifier(null, mod.second)); + } + } + + for(Element elAttribute : el.getChildren("attribute")) { + Pair mod = XMLUtils.parseItemAttributeModifier(elAttribute); + modifiers.put(mod.first, mod.second); + } + + return modifiers; + } + + public List parsePotionEffects(Element el) throws InvalidXMLException { + List effects = new ArrayList<>(); + + Node attr = Node.fromAttr(el, "effect", "effects", "potions"); + if(attr != null) { + for(String piece : attr.getValue().split(";")) { + effects.add(checkPotionEffect(XMLUtils.parseCompactPotionEffect(attr, piece), attr)); + } + } + + for(Element elPotion : XMLUtils.getChildren(el, "effect", "potion")) { + effects.add(parsePotionEffect(elPotion)); + } + + return effects; + } + + public PotionEffect parsePotionEffect(Element el) throws InvalidXMLException { + return checkPotionEffect(XMLUtils.parsePotionEffect(el), new Node(el)); + } + + private PotionEffect checkPotionEffect(PotionEffect effect, Node node) throws InvalidXMLException { + if(effect.getType().equals(PotionEffectType.HEALTH_BOOST) && effect.getAmplifier() < 0) { + if(effect.getDuration() != Integer.MAX_VALUE) { + // TODO: enable this check after existing maps are fixed + // throw new InvalidXMLException("Negative health boost effect must have infinite duration (use max-health instead)", node); + } + } + return effect; + } + +} diff --git a/PGM/src/main/java/tc/oc/pgm/kits/GrenadeListener.java b/PGM/src/main/java/tc/oc/pgm/kits/GrenadeListener.java new file mode 100644 index 0000000..d12401b --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/kits/GrenadeListener.java @@ -0,0 +1,60 @@ +package tc.oc.pgm.kits; + +import javax.inject.Inject; + +import org.bukkit.Material; +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.ProjectileHitEvent; +import org.bukkit.event.entity.ProjectileLaunchEvent; +import org.bukkit.inventory.ItemStack; +import org.bukkit.plugin.Plugin; +import tc.oc.commons.bukkit.util.NMSHacks; +import tc.oc.pgm.events.ListenerScope; +import tc.oc.pgm.kits.tag.Grenade; +import tc.oc.pgm.match.MatchScope; + +@ListenerScope(MatchScope.RUNNING) +public class GrenadeListener implements Listener { + + private final Plugin plugin; + + @Inject GrenadeListener(Plugin plugin) { + this.plugin = plugin; + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onGrenadeLaunch(final ProjectileLaunchEvent event) { + if (event.getEntity().getShooter() instanceof Player) { + Player player = (Player) event.getEntity().getShooter(); + ItemStack stack = player.getItemInHand(); + + if(stack != null) { + // special case for grenade arrows + if (stack.getType() == Material.BOW) { + int arrows = player.getInventory().first(Material.ARROW); + if (arrows == -1) return; + stack = player.getInventory().getItem(arrows); + } + + Grenade grenade = Grenade.ITEM_TAG.get(stack); + if(grenade != null) { + grenade.set(plugin, event.getEntity()); + } + } + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onGrenadeExplode(final ProjectileHitEvent event) { + if (event.getEntity().getShooter() instanceof Player) { + Grenade grenade = Grenade.get(event.getEntity()); + if(grenade != null) { + NMSHacks.createExplosion(event.getEntity(), event.getEntity().getLocation(), grenade.power, grenade.fire, grenade.destroy); + event.getEntity().remove(); + } + } + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/kits/HealthKit.java b/PGM/src/main/java/tc/oc/pgm/kits/HealthKit.java new file mode 100644 index 0000000..bcbf76e --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/kits/HealthKit.java @@ -0,0 +1,25 @@ +package tc.oc.pgm.kits; + +import com.google.common.base.Preconditions; +import tc.oc.pgm.match.MatchPlayer; + +public class HealthKit extends Kit.Impl { + protected final int halfHearts; + + public HealthKit(int halfHearts) { + Preconditions.checkArgument(0 < halfHearts && halfHearts <= 20, "halfHearts must be greater than 0 and less than or equal to 20"); + this.halfHearts = halfHearts; + } + + /** + * The force flag allows the kit to decrease the player's health + */ + @Override + public void apply(MatchPlayer player, boolean force, ItemKitApplicator items) { + // Trying to set health > max throws an exception + double newHealth = Math.min(halfHearts, player.getBukkit().getMaxHealth()); + if(force || player.getBukkit().getHealth() < newHealth) { + player.getBukkit().setHealth(newHealth); + } + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/kits/HitboxKit.java b/PGM/src/main/java/tc/oc/pgm/kits/HitboxKit.java new file mode 100644 index 0000000..8c306db --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/kits/HitboxKit.java @@ -0,0 +1,18 @@ +package tc.oc.pgm.kits; + +import tc.oc.pgm.damage.HitboxPlayerFacet; +import tc.oc.pgm.match.MatchPlayer; + +public class HitboxKit extends Kit.Impl { + + private final double width; + + public HitboxKit(double width) { + this.width = width; + } + + @Override + public void apply(MatchPlayer player, boolean force, ItemKitApplicator items) { + player.facet(HitboxPlayerFacet.class).setWidth(width); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/kits/HungerKit.java b/PGM/src/main/java/tc/oc/pgm/kits/HungerKit.java new file mode 100644 index 0000000..5fb203f --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/kits/HungerKit.java @@ -0,0 +1,30 @@ +package tc.oc.pgm.kits; + +import tc.oc.pgm.match.MatchPlayer; + +import javax.annotation.Nullable; + +public class HungerKit extends Kit.Impl { + @Nullable protected final Float saturation; + @Nullable protected final Integer foodLevel; + + public HungerKit(@Nullable Float saturation, @Nullable Integer foodLevel) { + this.saturation = saturation; + this.foodLevel = foodLevel; + } + + /** + * The force flag allows the kit to decrease the player's food levels + */ + @Override + public void apply(MatchPlayer player, boolean force, ItemKitApplicator items) { + if(this.saturation != null && (force || player.getBukkit().getSaturation() < this.saturation)) { + player.getBukkit().setSaturation(this.saturation); + } + + if(this.foodLevel != null && (force || player.getBukkit().getFoodLevel() < this.foodLevel)) { + player.getBukkit().setFoodLevel(this.foodLevel); + } + } + +} diff --git a/PGM/src/main/java/tc/oc/pgm/kits/ImpulseKit.java b/PGM/src/main/java/tc/oc/pgm/kits/ImpulseKit.java new file mode 100644 index 0000000..b491d15 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/kits/ImpulseKit.java @@ -0,0 +1,22 @@ +package tc.oc.pgm.kits; + +import org.bukkit.util.ImVector; +import org.bukkit.util.Vector; +import tc.oc.pgm.match.MatchPlayer; +import tc.oc.pgm.physics.RelativeFlags; + +public class ImpulseKit extends Kit.Impl { + + private final ImVector velocity; + private final RelativeFlags relative; + + public ImpulseKit(Vector velocity, RelativeFlags relative) { + this.velocity = ImVector.copyOf(velocity); + this.relative = relative; + } + + @Override + public void apply(MatchPlayer player, boolean force, ItemKitApplicator items) { + player.getBukkit().setVelocity(relative.getTransform(player.getLocation()).apply(velocity)); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/kits/ItemKit.java b/PGM/src/main/java/tc/oc/pgm/kits/ItemKit.java new file mode 100644 index 0000000..4fb721f --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/kits/ItemKit.java @@ -0,0 +1,26 @@ +package tc.oc.pgm.kits; + +import java.util.Map; +import java.util.Optional; + +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.registry.Key; +import tc.oc.commons.bukkit.item.ItemUtils; +import tc.oc.commons.bukkit.util.NMSHacks; +import tc.oc.commons.core.inspect.Inspectable; +import tc.oc.commons.core.util.MapUtils; + +public interface ItemKit extends Kit { + ItemStack item(); +} + +abstract class BaseItemKit extends Kit.Impl implements ItemKit { + @Inspect private Material material() { return item().getType(); } + @Inspect private int damage() { return item().getDurability(); } + @Inspect private int amount() { return item().getAmount(); } + @Inspect private Optional meta() { return ItemUtils.tryMeta(item()); } + @Inspect private Map enchants() { return MapUtils.transformKeys(item().getEnchantments(), + enchantment -> NMSHacks.getKey(enchantment)); } +} \ No newline at end of file diff --git a/PGM/src/main/java/tc/oc/pgm/kits/ItemKitApplicator.java b/PGM/src/main/java/tc/oc/pgm/kits/ItemKitApplicator.java new file mode 100644 index 0000000..1a92c1b --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/kits/ItemKitApplicator.java @@ -0,0 +1,135 @@ +package tc.oc.pgm.kits; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import com.google.common.collect.Iterables; +import com.google.common.collect.Lists; +import com.google.common.collect.Maps; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +import tc.oc.commons.bukkit.inventory.InventorySlot; +import tc.oc.commons.bukkit.inventory.InventoryUtils; +import tc.oc.commons.bukkit.inventory.Slot; +import tc.oc.commons.bukkit.item.ItemUtils; +import tc.oc.pgm.events.ItemTransferEvent; +import tc.oc.pgm.events.PlayerItemTransferEvent; +import tc.oc.pgm.match.MatchPlayer; + +public class ItemKitApplicator { + + private final Map hardItems = new HashMap<>(); + private final Map softItems = new HashMap<>(); + private final List freeItems = new ArrayList<>(); + + public void add(ItemStack item) { + freeItems.add(item); + } + + public void put(Slot slot, ItemStack item, boolean force) { + (force ? hardItems : softItems).put(slot, item); + } + + public boolean isEmpty(Slot slot) { + return ItemUtils.isNothing(softItems.get(slot)); + } + + public void apply(MatchPlayer player) { + final PlayerInventory inv = player.getInventory(); + + // Place forced items first + hardItems.forEach((slot, stack) -> fireEventAndTransfer(player, slot, stack, false)); + + final Map softItems = new HashMap<>(Maps.transformValues(this.softItems, ItemStack::clone)); + final List freeItems = new ArrayList<>(Lists.transform(this.freeItems, ItemStack::clone)); + final Iterable kitItems = Iterables.concat(softItems.values(), freeItems); + + // Tools in the player's inv are repaired using matching tools in the kit with less damage + for(ItemStack kitStack : kitItems) { + for(ItemStack invStack : inv.contents()) { + if(invStack != null) { + if(kitStack.getAmount() > 0 && + kitStack.getType().getMaxDurability() > 0 && + kitStack.getType().equals(invStack.getType()) && + kitStack.getEnchantments().equals(invStack.getEnchantments()) && + kitStack.getDurability() < invStack.getDurability()) { + + invStack.setDurability(kitStack.getDurability()); + kitStack.setAmount(0); + break; + } + } + } + } + + // Items in the player's inv that stack with kit items are deducted from the kit + for(ItemStack invStack : inv.contents()) { + if(invStack != null) { + int amount = invStack.getAmount(); + + for(ItemStack kitStack : kitItems) { + if(amount <= 0) break; + + if(kitStack.isSimilar(invStack)) { + int reduce = Math.min(amount, kitStack.getAmount()); + if(reduce > 0) { + amount -= reduce; + ItemUtils.addAmount(kitStack, -reduce); + } + } + } + } + } + + // Fill partial stacks of kit items that are already in the player's inv. + // We must do this in a seperate pass so that kit stacks don't combine with + // other kit stacks. + for(ItemStack kitStack : kitItems) { + InventoryUtils.similar(inv, kitStack).forEach(slot -> { + final int quantity = slot.maxTransferrableIn(kitStack, inv); + if(quantity > 0) { + fireEventAndTransfer(player, slot, kitStack, true); + ItemUtils.addAmount(kitStack, -quantity); + } + }); + } + + // Put the remaining kit slotted items into their designated inv slots. + // If a slot is occupied, add the stack to freeItems. + softItems.forEach((kitSlot, kitStack) -> { + if(kitStack.getAmount() > 0) { + if(kitSlot.isEmpty(inv)) { + fireEventAndTransfer(player, kitSlot, kitStack, false); + } else { + freeItems.add(kitStack); + } + } + }); + + // Add free items to the inventory one at a time, firing an event + // for each partial stack transferred. + freeItems.forEach(stack -> fireEventAndTransfer(player, stack)); + } + + public static void fireEventAndTransfer(MatchPlayer player, ItemStack stack) { + InventoryUtils.chooseStorageSlots(player.getInventory(), stack) + .forEach((slot, partial) -> fireEventAndTransfer(player, slot, partial, true)); + } + + public static boolean fireEventAndTransfer(MatchPlayer player, Slot slot, ItemStack stack, boolean combine) { + final PlayerItemTransferEvent event = new PlayerItemTransferEvent(null, ItemTransferEvent.Type.PLUGIN, player.getBukkit(), + Optional.empty(), + Optional.of(new InventorySlot<>(player.getInventory(), slot)), + stack, null, stack.getAmount(), null); + player.getMatch().callEvent(event); + if(event.isCancelled()) return false; + + stack = event.getItemStack().clone(); + stack.setAmount(event.getQuantity() + (combine ? ItemUtils.amount(slot.getItem(player)) : 0)); + slot.putItem(player, stack); + return true; + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/kits/ItemParser.java b/PGM/src/main/java/tc/oc/pgm/kits/ItemParser.java new file mode 100644 index 0000000..991a4cb --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/kits/ItemParser.java @@ -0,0 +1,36 @@ +package tc.oc.pgm.kits; + +import javax.inject.Inject; + +import org.bukkit.Material; +import org.bukkit.inventory.meta.ItemMeta; +import org.jdom2.Element; +import tc.oc.pgm.features.FeatureDefinitionContext; +import tc.oc.pgm.map.MapModule; +import tc.oc.pgm.projectile.ProjectileDefinition; +import tc.oc.pgm.projectile.Projectiles; +import tc.oc.pgm.xml.InvalidXMLException; +import tc.oc.pgm.xml.Node; +import tc.oc.pgm.xml.parser.Parser; + +import static tc.oc.commons.core.exception.LambdaExceptionUtils.rethrowConsumer; + +public class ItemParser extends GlobalItemParser implements MapModule { + + private final FeatureDefinitionContext fdc; + + @Inject private ItemParser(Parser materialParser, FeatureDefinitionContext fdc) { + super(materialParser); + this.fdc = fdc; + } + + @Override + public void parseCustomNBT(Element el, ItemMeta meta) throws InvalidXMLException { + super.parseCustomNBT(el, meta); + + Node.tryAttr(el, "projectile").ifPresent(rethrowConsumer(node -> { + fdc.reference(node, ProjectileDefinition.class); + Projectiles.setProjectileId(meta, node.getValue()); + })); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/kits/ItemSharingAndLockingListener.java b/PGM/src/main/java/tc/oc/pgm/kits/ItemSharingAndLockingListener.java new file mode 100644 index 0000000..3467950 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/kits/ItemSharingAndLockingListener.java @@ -0,0 +1,112 @@ +package tc.oc.pgm.kits; + +import java.util.Iterator; +import java.util.Objects; +import javax.annotation.Nullable; +import javax.inject.Inject; + +import net.md_5.bungee.api.chat.TranslatableComponent; +import org.bukkit.entity.HumanEntity; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.PlayerDeathEvent; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryCreativeEvent; +import org.bukkit.event.player.PlayerDropItemEvent; +import org.bukkit.inventory.ItemStack; +import tc.oc.commons.bukkit.inventory.Slot; +import tc.oc.pgm.events.ItemTransferEvent; +import tc.oc.pgm.events.ListenerScope; +import tc.oc.pgm.kits.tag.ItemTags; +import tc.oc.pgm.match.MatchPlayerFinder; +import tc.oc.pgm.match.MatchScope; + +@ListenerScope(MatchScope.LOADED) +public class ItemSharingAndLockingListener implements Listener { + + private final MatchPlayerFinder playerFinder; + + @Inject private ItemSharingAndLockingListener(MatchPlayerFinder playerFinder) { + this.playerFinder = playerFinder; + } + + private boolean isLocked(@Nullable ItemStack item) { + return item != null && ItemTags.LOCKED.get(item); + } + + private boolean isUnshareable(@Nullable ItemStack item) { + return item != null && (isLocked(item) || ItemTags.PREVENT_SHARING.get(item)); + } + + private void sendLockWarning(HumanEntity player) { + playerFinder.player(player).ifPresent( + mp -> mp.sendWarning(new TranslatableComponent("item.locked"), true) + ); + } + + @EventHandler(priority = EventPriority.LOW, ignoreCancelled = true) + public void onInventoryClick(final InventoryClickEvent event) { + if(event instanceof InventoryCreativeEvent) return;; + + // Ensure the player is clicking in their own inventory + // TODO: should we allow items to be locked into other types of inventories? + if(!Objects.equals(event.getWhoClicked(), event.getInventory().getHolder())) return; + + // Break out of the switch if the action will move a locked item, otherwise return + switch(event.getAction()) { + case HOTBAR_SWAP: + case HOTBAR_MOVE_AND_READD: + // These actions can move up to two stacks. Check the hotbar stack, + // and then fall through to check the stack under the cursor. + if(isLocked(Slot.Hotbar.forPosition(event.getHotbarButton()) + .getItem(event.getWhoClicked().getInventory()))) break; + // fall through + + case PICKUP_ALL: + case PICKUP_HALF: + case PICKUP_SOME: + case PICKUP_ONE: + case SWAP_WITH_CURSOR: + case MOVE_TO_OTHER_INVENTORY: + case DROP_ONE_SLOT: + case DROP_ALL_SLOT: + case COLLECT_TO_CURSOR: + // All these actions move only a single stack, except COLLECT_TO_CURSOR, + // which can only move items that are stackable with the one under the cursor, + // and locked items are only stackable with other locked items. + if(isLocked(event.getCurrentItem())) break; + + // fall through + + default: return; + } + + event.setCancelled(true); + sendLockWarning(event.getWhoClicked()); + } + + @EventHandler(priority = EventPriority.LOW, ignoreCancelled = true) + public void onDropItem(PlayerDropItemEvent event) { + if(isLocked(event.getItemDrop().getItemStack())) { + event.setCancelled(true); + sendLockWarning(event.getPlayer()); + } else if(isUnshareable(event.getItemDrop().getItemStack())) { + event.getItemDrop().remove(); + } + } + + @EventHandler(priority = EventPriority.LOW, ignoreCancelled = true) + public void onTransferItem(ItemTransferEvent event) { + if(event.getType() == ItemTransferEvent.Type.PLACE && isUnshareable(event.getItemStack())) { + event.setCancelled(true); + } + } + + @EventHandler(priority = EventPriority.LOW, ignoreCancelled = true) + public void onDeath(PlayerDeathEvent event) { + for(Iterator iterator = event.getDrops().iterator(); iterator.hasNext(); ) { + if(isUnshareable(iterator.next())) iterator.remove();; + } + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/kits/Kit.java b/PGM/src/main/java/tc/oc/pgm/kits/Kit.java new file mode 100644 index 0000000..f442df6 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/kits/Kit.java @@ -0,0 +1,63 @@ +package tc.oc.pgm.kits; + +import org.bukkit.entity.Player; +import tc.oc.pgm.features.FeatureDefinition; +import tc.oc.pgm.features.FeatureInfo; +import tc.oc.pgm.match.MatchPlayer; +import tc.oc.pgm.match.MatchScope; + +@FeatureInfo(name = "kit") +public interface Kit extends FeatureDefinition { + /** + * Apply this kit to the given player. If force is true, the player's state is made + * to match the kit as strictly as possible, otherwise the kit may be given to the + * player in a way that is more in their best interest. Subclasses will interpret + * these concepts in their own way. + * + * A mutable List must be given, to which the Kit may add ItemStacks that could not + * be applied normally, because the player's inventory was full. These stacks will + * be given to the player using the natural give algorithm after ALL kits have been + * applied. This phase must be deferred in this way so that overflow from one kit + * does not displace stacks in another kit applied simultaneously. In this way, the + * number of stacks that go to their proper slots is maximized. + */ + void apply(MatchPlayer player, boolean force, ItemKitApplicator items); + + default void remove(MatchPlayer player) { + throw new UnsupportedOperationException(this + " is not removable"); + } + + default boolean isRemovable() { + return false; + } + + default void apply(MatchPlayer player) { + apply(player, false); + } + + default void apply(MatchPlayer player, boolean force) { + final ItemKitApplicator items = new ItemKitApplicator(); + apply(player, force, items); + items.apply(player); + + /** + * When max health is lowered by an item attribute or potion effect, the client can + * go into an inconsistent state that has strange effects, like the death animation + * playing when the player isn't dead. It is probably related to this bug: + * + * https://bugs.mojang.com/browse/MC-19690 + * + * This appears to fix the client state, for reasons that are unclear. The one tick + * delay is necessary. Any less and getMaxHealth will not reflect whatever was + * applied in the kit to modify it. + */ + final Player bukkit = player.getBukkit(); + player.getMatch().getScheduler(MatchScope.LOADED).createDelayedTask(1, () -> { + if(bukkit.isOnline() && !player.isDead() && bukkit.getMaxHealth() < 20) { + bukkit.setHealth(Math.min(bukkit.getHealth(), bukkit.getMaxHealth())); + } + }); + } + + abstract class Impl extends FeatureDefinition.Impl implements Kit {} +} diff --git a/PGM/src/main/java/tc/oc/pgm/kits/KitDefinitionParser.java b/PGM/src/main/java/tc/oc/pgm/kits/KitDefinitionParser.java new file mode 100644 index 0000000..de4cd95 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/kits/KitDefinitionParser.java @@ -0,0 +1,257 @@ +package tc.oc.pgm.kits; + +import java.util.Optional; +import java.util.Set; +import java.util.stream.Stream; +import javax.inject.Inject; + +import com.google.common.collect.Range; +import org.bukkit.inventory.ItemStack; +import org.jdom2.Element; +import tc.oc.commons.bukkit.inventory.ArmorType; +import tc.oc.commons.bukkit.inventory.Slot; +import tc.oc.pgm.compose.CompositionParser; +import tc.oc.pgm.doublejump.DoubleJumpKit; +import tc.oc.pgm.features.FeatureDefinitionContext; +import tc.oc.pgm.features.FeatureDefinitionParser; +import tc.oc.pgm.features.FeatureParser; +import tc.oc.pgm.features.MagicMethodFeatureParser; +import tc.oc.pgm.filters.matcher.StaticFilter; +import tc.oc.pgm.filters.parser.FilterParser; +import tc.oc.pgm.itemmeta.ItemModifier; +import tc.oc.pgm.map.MapLogger; +import tc.oc.pgm.physics.RelativeFlags; +import tc.oc.pgm.shield.ShieldKit; +import tc.oc.pgm.shield.ShieldParameters; +import tc.oc.pgm.teams.TeamFactory; +import tc.oc.pgm.utils.AllMaterialMatcher; +import tc.oc.pgm.utils.MethodParser; +import tc.oc.pgm.utils.XMLUtils; +import tc.oc.pgm.xml.InvalidXMLException; +import tc.oc.pgm.xml.Node; +import tc.oc.pgm.xml.NodeSplitter; +import tc.oc.pgm.xml.property.PropertyBuilderFactory; + +import static tc.oc.commons.core.exception.LambdaExceptionUtils.rethrowFunction; +import static tc.oc.commons.core.stream.Collectors.toImmutableSet; + +public class KitDefinitionParser extends MagicMethodFeatureParser implements FeatureDefinitionParser { + + @Inject protected FeatureDefinitionContext features; + @Inject protected ItemParser itemParser; + @Inject protected FilterParser filterParser; + @Inject protected FeatureParser teamParser; + @Inject protected CompositionParser composer; + @Inject protected KitParser kitParser; + @Inject protected PropertyBuilderFactory booleanParser; + @Inject protected MapLogger mapLogger; + @Inject protected ItemModifier itemModifier; + + private Stream parseParentKits(Element el) throws InvalidXMLException { + return Node.attributes(el, "parent", "parents") + .flatMap(attr -> NodeSplitter.LIST.split(attr) + .map(rethrowFunction(id -> kitParser.parseReference(attr, id.trim())))); + } + + // TODO: this check can be removed after a little while + private static final Set LEGACY_ACTION_ATTRS = Stream.of(KitRule.Action.values()) + .map(action -> action.name().toLowerCase()) + .collect(toImmutableSet()); + @MethodParser + Kit kit(Element el) throws InvalidXMLException { + Node.nodes(el, LEGACY_ACTION_ATTRS).forEach(attr -> + mapLogger.warning(attr, "Kit give/take/lend attributes are no longer supported") + ); + + return new KitNodeImpl( + parseParentKits(el), + composer.parseElement(el), + filterParser.property(el, "filter").optional(StaticFilter.ALLOW), + booleanParser.property(el, "force").elements(false).optional(), // attrs only, since is a type of kit + booleanParser.property(el, "potion-particles").optional() + ); + } + + protected Kit makeItemKit(Optional slot, ItemStack item) throws InvalidXMLException { + final ItemStack modded = itemModifier.modify(item).immutableCopy(); + return slot.map(s -> (Kit) new SlotItemKit(modded, s)) + .orElseGet(() -> new FreeItemKit(modded)); + } + + public Kit parseArmorKit(Element el, ArmorType type) throws InvalidXMLException { + return makeItemKit(Optional.of(Slot.Armor.forType(type)), itemParser.parseItem(el, true)); + } + + public Optional parseSlot(Element el) throws InvalidXMLException { + return Optional.ofNullable(Node.fromAttr(el, "slot")) + .map(rethrowFunction(XMLUtils::parsePlayerSlot)); + } + + @MethodParser + public Kit item(Element el) throws InvalidXMLException { + return makeItemKit(parseSlot(el), itemParser.parseItem(el, true)); + } + + @MethodParser + public Kit book(Element el) throws InvalidXMLException { + return makeItemKit(parseSlot(el), itemParser.parseBook(el)); + } + + @MethodParser + public Kit head(Element el) throws InvalidXMLException { + return makeItemKit(parseSlot(el), itemParser.parseHead(el)); + } + + @MethodParser public Kit boots(Element el) throws InvalidXMLException { return parseArmorKit(el, ArmorType.BOOTS); } + @MethodParser public Kit leggings(Element el) throws InvalidXMLException { return parseArmorKit(el, ArmorType.LEGGINGS); } + @MethodParser public Kit chestplate(Element el) throws InvalidXMLException { return parseArmorKit(el, ArmorType.CHESTPLATE); } + @MethodParser public Kit helmet(Element el) throws InvalidXMLException { return parseArmorKit(el, ArmorType.HELMET); } + + @MethodParser + public Kit clear(Element el) throws InvalidXMLException { + return new ClearKit( + parseSlot(el), + XMLUtils.parseMaterialMatcher(el, AllMaterialMatcher.INSTANCE) + ); + } + + @MethodParser + public Kit clear_items(Element el) throws InvalidXMLException { + return new ClearItemsKit(); + } + + @MethodParser + public Kit knockback_reduction(Element el) throws InvalidXMLException { + return new KnockbackReductionKit(XMLUtils.parseNumber(el, Float.class)); + } + + @MethodParser + public Kit walk_speed(Element el) throws InvalidXMLException { + return new WalkSpeedKit(XMLUtils.parseNumber(el, Float.class, Range.closed(WalkSpeedKit.MIN, WalkSpeedKit.MAX))); + } + + /* + ~ {FlyKit: allowFlight = true, flying = null } + ~ {FlyKit: allowFlight = true, flying = false } + ~ {FlyKit: allowFlight = false, flying = null } + ~ {FlyKit: allowFlight = true, flying = true } + */ + @MethodParser + public Kit fly(Element el) throws InvalidXMLException { + final boolean canFly = XMLUtils.parseBoolean(el.getAttribute("can-fly"), true); + final Boolean flying = XMLUtils.parseBoolean(el.getAttribute("flying"), null); + org.jdom2.Attribute flySpeedAtt = el.getAttribute("fly-speed"); + float flySpeedMultiplier = 1; + if(flySpeedAtt != null) { + flySpeedMultiplier = XMLUtils.parseNumber(el.getAttribute("fly-speed"), Float.class, Range.closed(FlyKit.MIN, FlyKit.MAX)); + } + + return new FlyKit(canFly, flying, flySpeedMultiplier); + } + + @MethodParser({"effect", "potion"}) + public Kit effect(Element el) throws InvalidXMLException { + return new PotionKit(itemParser.parsePotionEffect(el)); + } + + + @MethodParser + public Kit attribute(Element el) throws InvalidXMLException { + return new AttributeKit(XMLUtils.parseAttributeModifier(el)); + } + + @MethodParser + public Kit health(Element el) throws InvalidXMLException { + int health = XMLUtils.parseNumber(el, Integer.class); + if(health < 1 || health > 20) { + throw new InvalidXMLException(health + " is not a valid health value, must be between 1 and 20", el); + } + return new HealthKit(health); + } + + @MethodParser + public Kit max_health(Element el) throws InvalidXMLException { + return new MaxHealthKit(XMLUtils.parseNumber(el, Double.class, Range.atLeast(1d))); + } + + @MethodParser + public Kit saturation(Element el) throws InvalidXMLException { + return new HungerKit(XMLUtils.parseNumber(el, Float.class, Range.atLeast(0f)), null); + } + + @MethodParser + public Kit foodlevel(Element el) throws InvalidXMLException { + return new HungerKit(null, XMLUtils.parseNumber(el, Integer.class, Range.atLeast(0))); + } + + @MethodParser + public Kit double_jump(Element el) throws InvalidXMLException { + return new DoubleJumpKit(XMLUtils.parseBoolean(el.getAttribute("enabled"), true), + XMLUtils.parseNumber(el.getAttribute("power"), Float.class, DoubleJumpKit.DEFAULT_POWER), + XMLUtils.parseDuration(el.getAttribute("recharge-time"), DoubleJumpKit.DEFAULT_RECHARGE), + XMLUtils.parseBoolean(el.getAttribute("recharge-before-landing"), false)); + } + + @MethodParser + public Kit reser_ender_pearls(Element el) throws InvalidXMLException { + return new ResetEnderPearlsKit(); + } + + @MethodParser + public Kit game_mode(Element el) throws InvalidXMLException { + return new GameModeKit(XMLUtils.parseGameMode(new Node(el))); + } + + @MethodParser + public Kit shield(Element el) throws InvalidXMLException { + return new ShieldKit(new ShieldParameters(XMLUtils.parseNumber(el.getAttribute("health"), Double.class, ShieldParameters.DEFAULT_HEALTH), + XMLUtils.parseDuration(el.getAttribute("delay"), ShieldParameters.DEFAULT_DELAY))); + } + + @MethodParser + public Kit fast_regeneration(Element el) throws InvalidXMLException { + return new NaturalRegenerationKit(true, XMLUtils.parseBoolean(new Node(el))); + } + + @MethodParser + public Kit slow_regeneration(Element el) throws InvalidXMLException { + return new NaturalRegenerationKit(false, XMLUtils.parseBoolean(new Node(el))); + } + + @MethodParser + public Kit hitbox(Element el) throws InvalidXMLException { + return new HitboxKit(XMLUtils.parseNumber(Node.fromRequiredAttr(el, "width"), Double.class)); + } + + @MethodParser + private Kit remove(Element el) throws InvalidXMLException { + return features.validate(new RemoveKit(kitParser.parseReferenceElement(el)), new Node(el), RemovableValidation.get()); + } + + @MethodParser + private Kit team_switch(Element el) throws InvalidXMLException { + return new TeamSwitchKit(teamParser.property(el, "team").required()); + } + + @MethodParser + private Kit eliminate(Element el) throws InvalidXMLException { + return new EliminateKit(); + } + + private RelativeFlags parseRelativeFlags(Element el) throws InvalidXMLException { + return RelativeFlags.of(XMLUtils.parseBoolean(el.getAttribute("yaw"), false), + XMLUtils.parseBoolean(el.getAttribute("pitch"), false)); + } + + @MethodParser + private Kit impulse(Element el) throws InvalidXMLException { + return new ImpulseKit(XMLUtils.parseVector(new Node(el)), + parseRelativeFlags(el)); + } + + @MethodParser + private Kit force(Element el) throws InvalidXMLException { + return new ForceKit(XMLUtils.parseVector(new Node(el)), + parseRelativeFlags(el)); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/kits/KitManifest.java b/PGM/src/main/java/tc/oc/pgm/kits/KitManifest.java new file mode 100644 index 0000000..8a39327 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/kits/KitManifest.java @@ -0,0 +1,55 @@ +package tc.oc.pgm.kits; + +import org.bukkit.inventory.ItemStack; +import tc.oc.commons.core.inject.HybridManifest; +import tc.oc.pgm.compose.ComposableManifest; +import tc.oc.pgm.features.FeatureBinder; +import tc.oc.pgm.map.inject.MapScoped; +import tc.oc.pgm.match.MatchPlayerFacetBinder; +import tc.oc.pgm.match.inject.MatchBinders; +import tc.oc.pgm.match.inject.MatchScoped; +import tc.oc.pgm.xml.parser.EnumParserManifest; +import tc.oc.pgm.xml.parser.ParserBinders; + +public class KitManifest extends HybridManifest implements ParserBinders, MatchBinders { + + @Override + protected void configure() { + // Items + bind(GlobalItemParser.class); + bind(ItemParser.class).in(MapScoped.class); + linkOptional(ItemParser.class); + bindElementParser(ItemStack.class).to(ItemParser.class); + + // Custom Items + bind(GrenadeListener.class).in(MatchScoped.class); + matchListener(GrenadeListener.class); + + bind(ItemSharingAndLockingListener.class).in(MatchScoped.class); + matchListener(ItemSharingAndLockingListener.class); + + // Kits + bind(KitDefinitionParser.class).in(MapScoped.class); + bind(KitParser.class).in(MapScoped.class); + linkOptional(KitParser.class); + + final FeatureBinder kits = new FeatureBinder<>(binder(), Kit.class); + kits.bindParser().to(KitParser.class); + kits.bindDefinitionParser().to(KitDefinitionParser.class); + kits.installRootParser(); + + install(new ComposableManifest(){}); + + installPlayerModule(binder -> { + new MatchPlayerFacetBinder(binder) + .register(KitPlayerFacet.class); + }); + + // KitRules + final FeatureBinder kitRules = new FeatureBinder<>(binder(), KitRule.class); + kitRules.installReflectiveParser(); + kitRules.installRootParser(); + install(new EnumParserManifest<>(KitRule.Action.class)); + } +} + diff --git a/PGM/src/main/java/tc/oc/pgm/kits/KitNode.java b/PGM/src/main/java/tc/oc/pgm/kits/KitNode.java new file mode 100644 index 0000000..0221d6e --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/kits/KitNode.java @@ -0,0 +1,89 @@ +package tc.oc.pgm.kits; + +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import tc.oc.commons.core.util.Optionals; +import tc.oc.pgm.compose.All; +import tc.oc.pgm.compose.Composition; +import tc.oc.pgm.compose.None; +import tc.oc.pgm.compose.Unit; +import tc.oc.pgm.filters.Filter; +import tc.oc.pgm.filters.matcher.StaticFilter; +import tc.oc.pgm.filters.query.TransientPlayerQuery; +import tc.oc.pgm.match.MatchPlayer; + +import static com.google.common.base.Preconditions.checkNotNull; +import static java.util.Optional.empty; + +public interface KitNode extends Kit { + + static KitNode of(Stream kits) { + return new KitNodeImpl(Stream.empty(), new All<>(kits.map(Unit::new)), StaticFilter.ALLOW, empty(), empty()); + } + + static KitNode of(Kit... kits) { + return of(Stream.of(kits)); + } + + static KitNode of(Collection kits) { + return of(kits.stream()); + } + + KitNode EMPTY = new KitNodeImpl(Stream.empty(), new None<>(), StaticFilter.ALLOW, empty(), empty()); +} + +class KitNodeImpl extends Kit.Impl implements KitNode, Kit { + private final @Inspect List parents; + private final @Inspect Composition kits; + private final @Inspect Filter filter; + private final @Inspect Optional force; + private final @Inspect Optional potionParticles; + + public KitNodeImpl(Stream parents, Composition kits, Filter filter, Optional force, Optional potionParticles) { + this.parents = parents.collect(Collectors.toList()); + this.kits = kits; + this.filter = checkNotNull(filter); + this.force = force; + this.potionParticles = potionParticles; + } + + @Inspect private Optional filter() { + // Hide default value + return Optionals.filter(filter, f -> !Objects.equals(f, StaticFilter.ALLOW)); + } + + @Override + public Stream dependencies() { + return Stream.concat(parents.stream(), kits.dependencies()); + } + + @Override + public void apply(MatchPlayer player, boolean force, ItemKitApplicator items) { + if(this.filter.query(player).isAllowed()) { + for(Kit kit : parents) { + kit.apply(player, this.force.orElse(force), items); + } + kits.elements(new TransientPlayerQuery(player)).forEach( + kit -> kit.apply(player, this.force.orElse(force), items) + ); + potionParticles.ifPresent(player.getBukkit()::setPotionParticles); + } + } + + @Override + public boolean isRemovable() { + return kits.isConstant(); + } + + @Override + public void remove(MatchPlayer player) { + kits.elements(new TransientPlayerQuery(player)).forEach( + kit -> kit.remove(player) + ); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/kits/KitParser.java b/PGM/src/main/java/tc/oc/pgm/kits/KitParser.java new file mode 100644 index 0000000..ddb754a --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/kits/KitParser.java @@ -0,0 +1,76 @@ +package tc.oc.pgm.kits; + +import java.util.Optional; +import java.util.stream.Stream; + +import com.google.common.collect.ImmutableList; +import org.jdom2.Element; +import tc.oc.pgm.features.LegacyFeatureParser; +import tc.oc.pgm.map.MapModule; +import tc.oc.pgm.xml.InvalidXMLException; + +public class KitParser extends LegacyFeatureParser implements MapModule { + + protected boolean hasParentsOrChildren(Element el) { + return el.getAttribute("parent") != null || + el.getAttribute("parents") != null || + !el.getChildren().isEmpty(); + } + + @Override + public boolean isReference(Element el) throws InvalidXMLException { + if(el.getAttribute(idAttributeName()) == null || + hasParentsOrChildren(el)) return false; + + if(legacy) { + // Default conditions are too strict for legacy XML (why?) + return "kit".equals(el.getName()); + } + + return super.isReference(el); + } + + @Override + public Optional parseDefinitionId(Element el, Kit definition) throws InvalidXMLException { + if(legacy && !"kit".equals(el.getName())) { + // Only parse the 'name' attribute as an ID on elements, + // because item kits use 'name' for something else + return Optional.empty(); + } + return super.parseDefinitionId(el, definition); + } + + @Override + public Stream parseChildren(Element parent) throws InvalidXMLException { + return super.parseChildren(parent); + } + + @Override + protected boolean canIgnore(Element el) throws InvalidXMLException { + return super.canIgnore(el) || (el.getName().equals("filter") && + el.getParentElement() != null && + "kit".equals(el.getParentElement().getName())); + } + + @Override + public KitPropertyBuilder property(Element element) { + return property(element, propertyName()); + } + + @Override + public KitPropertyBuilder property(Element element, String name) { + return new KitPropertyBuilder(element, name); + } + + public class KitPropertyBuilder extends PropertyBuilder { + public KitPropertyBuilder(Element element, String name) { + super(element, name); + } + + @Override + protected void parseChild(ImmutableList.Builder results, Element child) throws InvalidXMLException { + // Parse property children as KitNodes, so they can have attributes + results.add(((KitDefinitionParser) definitionParser.get()).kit(child)); + } + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/kits/KitPlayerFacet.java b/PGM/src/main/java/tc/oc/pgm/kits/KitPlayerFacet.java new file mode 100644 index 0000000..bff4ade --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/kits/KitPlayerFacet.java @@ -0,0 +1,21 @@ +package tc.oc.pgm.kits; + +import javax.inject.Inject; + +import org.bukkit.event.Listener; +import tc.oc.pgm.match.MatchPlayer; +import tc.oc.pgm.match.MatchPlayerFacet; + +/** + * This used to do more, but currently just calls Kit.apply. + * + * It may have more uses in the future, so we keep it around. + */ +public class KitPlayerFacet implements MatchPlayerFacet, Listener { + + @Inject private MatchPlayer player; + + public void applyKit(Kit kit, boolean force) { + kit.apply(player, force); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/kits/KitRule.java b/PGM/src/main/java/tc/oc/pgm/kits/KitRule.java new file mode 100644 index 0000000..c3b65ed --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/kits/KitRule.java @@ -0,0 +1,64 @@ +package tc.oc.pgm.kits; + +import com.google.inject.ImplementedBy; +import tc.oc.pgm.features.FeatureDefinition; +import tc.oc.pgm.features.FeatureInfo; +import tc.oc.pgm.features.FeatureValidationContext; +import tc.oc.pgm.filters.Filter; +import tc.oc.pgm.filters.FilterMatchModule; +import tc.oc.pgm.filters.parser.DynamicFilterValidation; +import tc.oc.pgm.match.Match; +import tc.oc.pgm.match.MatchPlayer; +import tc.oc.pgm.xml.InvalidXMLException; +import tc.oc.pgm.xml.NodeSplitter; +import tc.oc.pgm.xml.finder.Parent; + +@FeatureInfo(name = "kit-rule", plural = "kits", singular = {"give", "take", "lend"}) +@ImplementedBy(KitRuleImpl.class) +public interface KitRule extends FeatureDefinition { + + enum Action { GIVE, TAKE, LEND } + + @Property @Nodes(Parent.class) @Split(NodeSplitter.Name.class) + Action action(); + + @Property + Kit kit(); + + @Property + @Validate(DynamicFilterValidation.class) + Filter filter(); +} + +abstract class KitRuleImpl extends FeatureDefinition.Impl implements KitRule { + @Override + public void validate(FeatureValidationContext context) throws InvalidXMLException { + if(action() == Action.TAKE || action() == Action.LEND) { + context.validate(kit(), RemovableValidation.get()); + } + } + + @Override + public void load(Match match) { + final FilterMatchModule fmm = match.needMatchModule(FilterMatchModule.class); + switch(action()) { + case GIVE: + fmm.onRise(MatchPlayer.class, filter(), kit()::apply); + break; + + case TAKE: + fmm.onRise(MatchPlayer.class, filter(), kit()::remove); + break; + + case LEND: + fmm.onChange(MatchPlayer.class, filter(), (player, response) -> { + if(response) { + kit().apply(player); + } else { + kit().remove(player); + } + }); + break; + } + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/kits/KnockbackReductionKit.java b/PGM/src/main/java/tc/oc/pgm/kits/KnockbackReductionKit.java new file mode 100644 index 0000000..011c12c --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/kits/KnockbackReductionKit.java @@ -0,0 +1,26 @@ +package tc.oc.pgm.kits; + +import tc.oc.pgm.match.MatchPlayer; + +public class KnockbackReductionKit extends Kit.Impl { + private final float knockbackReduction; + + public KnockbackReductionKit(float reduction) { + this.knockbackReduction = reduction; + } + + @Override + public void apply(MatchPlayer player, boolean force, ItemKitApplicator items) { + player.getBukkit().setKnockbackReduction(this.knockbackReduction); + } + + @Override + public boolean isRemovable() { + return true; + } + + @Override + public void remove(MatchPlayer player) { + player.getBukkit().setKnockbackReduction(0); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/kits/MaxHealthKit.java b/PGM/src/main/java/tc/oc/pgm/kits/MaxHealthKit.java new file mode 100644 index 0000000..4c32561 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/kits/MaxHealthKit.java @@ -0,0 +1,30 @@ +package tc.oc.pgm.kits; + +import tc.oc.pgm.match.MatchPlayer; + +import static com.google.common.base.Preconditions.checkArgument; + +public class MaxHealthKit extends Kit.Impl { + + private final double maxHealth; + + public MaxHealthKit(double maxHealth) { + checkArgument(maxHealth > 0, "max health must be greater than zero"); + this.maxHealth = maxHealth; + } + + @Override + public void apply(MatchPlayer player, boolean force, ItemKitApplicator items) { + player.getBukkit().setMaxHealth(maxHealth); + } + + @Override + public boolean isRemovable() { + return true; + } + + @Override + public void remove(MatchPlayer player) { + player.getBukkit().setMaxHealth(20); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/kits/NaturalRegenerationKit.java b/PGM/src/main/java/tc/oc/pgm/kits/NaturalRegenerationKit.java new file mode 100644 index 0000000..7b76875 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/kits/NaturalRegenerationKit.java @@ -0,0 +1,22 @@ +package tc.oc.pgm.kits; + +import tc.oc.pgm.match.MatchPlayer; + +public class NaturalRegenerationKit extends Kit.Impl { + + private final boolean fast, enabled; + + public NaturalRegenerationKit(boolean fast, boolean enabled) { + this.fast = fast; + this.enabled = enabled; + } + + @Override + public void apply(MatchPlayer player, boolean force, ItemKitApplicator items) { + if(fast) { + player.getBukkit().setFastNaturalRegeneration(enabled); + } else { + player.getBukkit().setSlowNaturalRegeneration(enabled); + } + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/kits/PotionKit.java b/PGM/src/main/java/tc/oc/pgm/kits/PotionKit.java new file mode 100644 index 0000000..3cc1f12 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/kits/PotionKit.java @@ -0,0 +1,46 @@ +package tc.oc.pgm.kits; + +import org.bukkit.potion.PotionEffect; +import org.bukkit.potion.PotionEffectType; +import tc.oc.pgm.match.MatchPlayer; + +public class PotionKit extends Kit.Impl { + protected final PotionEffect effect; + + public PotionKit(PotionEffect effect) { + this.effect = effect; + } + + private void applyEffect(MatchPlayer player, boolean force) { + if(effect.getType().equals(PotionEffectType.HEALTH_BOOST)) { + // Convert negative HB to max-health kit + if(effect.getAmplifier() == -1 || effect.getDuration() == 0) { + // Level 0 or zero-duration HB resets max health + player.getBukkit().setMaxHealth(20); + return; + } else if(effect.getAmplifier() < -1 && effect.getDuration() == Integer.MAX_VALUE) { + // Level < 0 HB with inf duration converts to a MH kit + player.getBukkit().setMaxHealth(20 + (effect.getAmplifier() + 1) * 4); + return; + } + } + player.getBukkit().addPotionEffect(effect, force); + } + + @Override + public void apply(MatchPlayer player, boolean force, ItemKitApplicator items) { + applyEffect(player, force); + // No swirls by default, KitNode can re-enable them if it so desires + player.getBukkit().setPotionParticles(false); + } + + @Override + public boolean isRemovable() { + return true; + } + + @Override + public void remove(MatchPlayer player) { + player.getBukkit().removePotionEffect(effect.getType()); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/kits/RemovableValidation.java b/PGM/src/main/java/tc/oc/pgm/kits/RemovableValidation.java new file mode 100644 index 0000000..8c50b1c --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/kits/RemovableValidation.java @@ -0,0 +1,26 @@ +package tc.oc.pgm.kits; + +import tc.oc.pgm.xml.InvalidXMLException; +import tc.oc.pgm.xml.Node; +import tc.oc.pgm.xml.validate.Validation; + +import static tc.oc.commons.core.exception.LambdaExceptionUtils.rethrowConsumer; + +public class RemovableValidation implements Validation { + + private static final RemovableValidation INSTANCE = new RemovableValidation(); + public static RemovableValidation get() { + return INSTANCE; + } + + private RemovableValidation() {} + + @Override + public void validate(Kit root, Node node) throws InvalidXMLException { + root.deepDependencies(Kit.class).forEach(rethrowConsumer(kit -> { + if(!kit.isRemovable()) { + throw new InvalidXMLException("Kit type " + kit.getDefinitionType().getSimpleName() + " is not removable", node); + } + })); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/kits/RemoveKit.java b/PGM/src/main/java/tc/oc/pgm/kits/RemoveKit.java new file mode 100644 index 0000000..461a815 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/kits/RemoveKit.java @@ -0,0 +1,37 @@ +package tc.oc.pgm.kits; + +import java.util.stream.Stream; + +import tc.oc.pgm.match.MatchPlayer; + +public class RemoveKit extends Kit.Impl { + private final Kit kit; + + public RemoveKit(Kit kit) { + this.kit = kit; + } + + @Override + public Stream dependencies() { + return Stream.of(kit); + } + + public Kit getKit() { + return kit; + } + + @Override + public void apply(MatchPlayer player, boolean force, ItemKitApplicator items) { + kit.remove(player); + } + + @Override + public boolean isRemovable() { + return true; + } + + @Override + public void remove(MatchPlayer player) { + player.facet(KitPlayerFacet.class).applyKit(kit, false); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/kits/ResetEnderPearlsKit.java b/PGM/src/main/java/tc/oc/pgm/kits/ResetEnderPearlsKit.java new file mode 100644 index 0000000..a4f9e37 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/kits/ResetEnderPearlsKit.java @@ -0,0 +1,20 @@ +package tc.oc.pgm.kits; + +import org.bukkit.entity.EnderPearl; +import org.bukkit.entity.Player; +import tc.oc.pgm.match.MatchPlayer; + +/** + * Disowns any Ender Pearls the player has thrown + */ +public class ResetEnderPearlsKit extends Kit.Impl { + @Override + public void apply(MatchPlayer player, boolean force, ItemKitApplicator items) { + Player bukkitPlayer = player.getBukkit(); + for(EnderPearl pearl : bukkitPlayer.getWorld().getEntitiesByClass(EnderPearl.class)) { + if(pearl.getShooter() == bukkitPlayer) { + pearl.setShooter(null); + } + } + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/kits/SlotItemKit.java b/PGM/src/main/java/tc/oc/pgm/kits/SlotItemKit.java new file mode 100644 index 0000000..621be9d --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/kits/SlotItemKit.java @@ -0,0 +1,24 @@ +package tc.oc.pgm.kits; + +import org.bukkit.inventory.ItemStack; +import tc.oc.commons.bukkit.inventory.Slot; +import tc.oc.pgm.match.MatchPlayer; + +public class SlotItemKit extends FreeItemKit { + + protected final @Inspect Slot.Player slot; + + public SlotItemKit(ItemStack item, Slot.Player slot) { + super(item); + this.slot = slot; + } + + public Slot.Player slot() { + return slot; + } + + @Override + public void apply(MatchPlayer player, boolean force, ItemKitApplicator items) { + items.put(slot, item, force); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/kits/TeamSwitchKit.java b/PGM/src/main/java/tc/oc/pgm/kits/TeamSwitchKit.java new file mode 100644 index 0000000..f82c78a --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/kits/TeamSwitchKit.java @@ -0,0 +1,20 @@ +package tc.oc.pgm.kits; + +import tc.oc.pgm.match.MatchPlayer; +import tc.oc.pgm.teams.TeamFactory; +import tc.oc.pgm.teams.TeamMatchModule; + +public class TeamSwitchKit extends DelayedKit { + private final TeamFactory team; + + public TeamSwitchKit(TeamFactory team) { + this.team = team; + } + + @Override + public void applyDelayed(MatchPlayer player, boolean force) { + TeamMatchModule tmm = player.getMatch().needMatchModule(TeamMatchModule.class); + tmm.forceJoin(player, tmm.team(team)); + } + +} diff --git a/PGM/src/main/java/tc/oc/pgm/kits/WalkSpeedKit.java b/PGM/src/main/java/tc/oc/pgm/kits/WalkSpeedKit.java new file mode 100644 index 0000000..b178e80 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/kits/WalkSpeedKit.java @@ -0,0 +1,29 @@ +package tc.oc.pgm.kits; + +import tc.oc.pgm.match.MatchPlayer; + +public class WalkSpeedKit extends Kit.Impl { + public static final float MIN = 0, MAX = 5; + public static final float BUKKIT_DEFAULT = 0.2f; + + private final float speedMultiplier; + + public WalkSpeedKit(float speedMultiplier) { + this.speedMultiplier = speedMultiplier; + } + + @Override + public void apply(MatchPlayer player, boolean force, ItemKitApplicator items) { + player.getBukkit().setWalkSpeed(BUKKIT_DEFAULT * this.speedMultiplier); + } + + @Override + public boolean isRemovable() { + return true; + } + + @Override + public void remove(MatchPlayer player) { + player.getBukkit().setWalkSpeed(BUKKIT_DEFAULT); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/kits/tag/Grenade.java b/PGM/src/main/java/tc/oc/pgm/kits/tag/Grenade.java new file mode 100644 index 0000000..25860de --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/kits/tag/Grenade.java @@ -0,0 +1,35 @@ +package tc.oc.pgm.kits.tag; + +import org.bukkit.metadata.FixedMetadataValue; +import org.bukkit.metadata.Metadatable; +import org.bukkit.plugin.Plugin; + +import javax.annotation.Nullable; + +public class Grenade { + public final float power; + public final boolean fire; + public final boolean destroy; + + public Grenade(float power, boolean fire, boolean destroy) { + this.power = power; + this.fire = fire; + this.destroy = destroy; + } + + public static final GrenadeItemTag ITEM_TAG = new GrenadeItemTag(); + + private static final String METADATA_KEY = "grenade"; + + public static boolean is(Metadatable entity) { + return entity.hasMetadata(METADATA_KEY); + } + + public static @Nullable Grenade get(Metadatable entity) { + return entity.hasMetadata(METADATA_KEY) ? (Grenade) entity.getMetadata(METADATA_KEY).get(0).value() : null; + } + + public void set(Plugin plugin, Metadatable entity) { + entity.setMetadata(METADATA_KEY, new FixedMetadataValue(plugin, this)); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/kits/tag/GrenadeItemTag.java b/PGM/src/main/java/tc/oc/pgm/kits/tag/GrenadeItemTag.java new file mode 100644 index 0000000..ed3dfc6 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/kits/tag/GrenadeItemTag.java @@ -0,0 +1,37 @@ +package tc.oc.pgm.kits.tag; + +import net.minecraft.server.NBTTagCompound; +import tc.oc.commons.bukkit.item.BooleanItemTag; +import tc.oc.commons.bukkit.item.FloatItemTag; +import tc.oc.commons.bukkit.item.ItemTag; + +public class GrenadeItemTag extends ItemTag { + + protected static final FloatItemTag POWER = new FloatItemTag("power", 1f); + protected static final BooleanItemTag FIRE = new BooleanItemTag("fire", false); + protected static final BooleanItemTag DESTROY = new BooleanItemTag("destroy", true); + + public GrenadeItemTag() { + super("grenade", null); + } + + @Override + protected boolean hasPrimitive(NBTTagCompound tag) { + return tag.hasKeyOfType(name, 10); + } + + @Override + protected Grenade getPrimitive(NBTTagCompound tag) { + NBTTagCompound grenade = tag.getCompound(name); + return new Grenade(POWER.get(grenade), FIRE.get(grenade), DESTROY.get(grenade)); + } + + @Override + protected void setPrimitive(NBTTagCompound tag, Grenade value) { + NBTTagCompound grenade = tag.getCompound(name); + tag.set(name, grenade); + POWER.set(grenade, value.power); + FIRE.set(grenade, value.fire); + DESTROY.set(grenade, value.destroy); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/kits/tag/ItemTags.java b/PGM/src/main/java/tc/oc/pgm/kits/tag/ItemTags.java new file mode 100644 index 0000000..a7ef3ee --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/kits/tag/ItemTags.java @@ -0,0 +1,11 @@ +package tc.oc.pgm.kits.tag; + +import tc.oc.commons.bukkit.item.BooleanItemTag; + +public class ItemTags { + + public static final BooleanItemTag PREVENT_SHARING = new BooleanItemTag("prevent-sharing", false); + public static final BooleanItemTag LOCKED = new BooleanItemTag("locked", false); + + private ItemTags() {} +} diff --git a/PGM/src/main/java/tc/oc/pgm/lane/Lane.java b/PGM/src/main/java/tc/oc/pgm/lane/Lane.java new file mode 100644 index 0000000..17eefd2 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/lane/Lane.java @@ -0,0 +1,20 @@ +package tc.oc.pgm.lane; + +import java.util.List; + +import tc.oc.pgm.features.FeatureDefinition; +import tc.oc.pgm.features.FeatureInfo; +import tc.oc.pgm.regions.Region; +import tc.oc.pgm.teams.TeamFactory; +import tc.oc.pgm.xml.finder.AllChildren; + +@FeatureInfo(name = "lane") +public interface Lane extends FeatureDefinition { + + @Property + TeamFactory team(); + + @Property + @Nodes(AllChildren.class) + List regions(); +} diff --git a/PGM/src/main/java/tc/oc/pgm/lane/LaneManifest.java b/PGM/src/main/java/tc/oc/pgm/lane/LaneManifest.java new file mode 100644 index 0000000..8fd16bb --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/lane/LaneManifest.java @@ -0,0 +1,15 @@ +package tc.oc.pgm.lane; + +import tc.oc.commons.core.inject.HybridManifest; +import tc.oc.pgm.features.FeatureBinder; + +public class LaneManifest extends HybridManifest { + + @Override + protected void configure() { + final FeatureBinder lane = new FeatureBinder<>(binder(), Lane.class); + lane.installReflectiveParser(); + lane.installRootParser(); + lane.installMatchModule(LaneMatchModule.class); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/lane/LaneMatchModule.java b/PGM/src/main/java/tc/oc/pgm/lane/LaneMatchModule.java new file mode 100644 index 0000000..a00b836 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/lane/LaneMatchModule.java @@ -0,0 +1,170 @@ +package tc.oc.pgm.lane; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import javax.inject.Inject; + +import com.google.common.collect.ObjectArrays; +import com.google.common.collect.Sets; +import net.md_5.bungee.api.chat.TranslatableComponent; +import org.bukkit.ChatColor; +import org.bukkit.EntityLocation; +import org.bukkit.GameMode; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerTeleportEvent; +import org.bukkit.event.player.PlayerTeleportEvent.TeleportCause; +import org.bukkit.util.Vector; +import tc.oc.api.docs.PlayerId; +import tc.oc.commons.bukkit.event.CoarsePlayerMoveEvent; +import tc.oc.commons.bukkit.util.BlockUtils; +import tc.oc.commons.bukkit.util.Materials; +import tc.oc.commons.core.stream.BiStream; +import tc.oc.commons.core.stream.Collectors; +import tc.oc.pgm.PGMTranslations; +import tc.oc.pgm.events.BlockTransformEvent; +import tc.oc.pgm.events.ListenerScope; +import tc.oc.pgm.events.PlayerBlockTransformEvent; +import tc.oc.pgm.match.MatchModule; +import tc.oc.pgm.match.MatchPlayer; +import tc.oc.pgm.match.MatchScope; +import tc.oc.pgm.regions.Region; +import tc.oc.pgm.regions.Union; +import tc.oc.pgm.spawns.events.ParticipantDespawnEvent; +import tc.oc.pgm.teams.Team; +import tc.oc.pgm.teams.TeamMatchModule; + +@ListenerScope(MatchScope.RUNNING) +public class LaneMatchModule extends MatchModule implements Listener { + private final Map lanes; + private final Set voidPlayers = Sets.newHashSet(); + + @Inject private LaneMatchModule(List lanes, TeamMatchModule teams) { + this.lanes = BiStream.fromValues(lanes.stream(), lane -> teams.team(lane.team())) + .mapValues(lane -> Union.of(lane.regions())) + .collect(Collectors.toImmutableMap()); + } + + @Override + public void disable() { + this.voidPlayers.clear(); + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void checkLaneMovement(final CoarsePlayerMoveEvent event) { + MatchPlayer player = this.match.getPlayer(event.getPlayer()); + if(player == null || + !player.canInteract() || + !(player.getParty() instanceof Team) || + player.getBukkit().getGameMode() == GameMode.CREATIVE || + event.getTo().getY() <= 0) return; + + Region laneRegion = this.lanes.get(player.getParty()); + if(laneRegion == null) return; + + boolean containsFrom = laneRegion.contains(event.getBlockFrom().toVector()); + boolean containsTo = laneRegion.contains(event.getBlockTo().toVector()); + + // prevent ender pearling to the other lane + if(!containsTo && event.getCause() instanceof PlayerTeleportEvent) { + if(((PlayerTeleportEvent) event.getCause()).getCause() == TeleportCause.ENDER_PEARL) { + event.setCancelled(true, new TranslatableComponent("match.lane.enderPearl.disabled")); + return; + } + } + + if(this.voidPlayers.contains(player.getPlayerId())) { + event.getPlayer().setFallDistance(0); + // they have been marked as "out of lane" + if(containsTo && !containsFrom) { + // prevent the player from re-entering the lane + event.setCancelled(true, new TranslatableComponent("match.lane.reEntry.disabled")); + } else { + // if they are going to land on something, teleport them underneith it + Block under = event.getTo().clone().add(new Vector(0, -1, 0)).getBlock(); + if(under != null && under.getType() != Material.AIR) { + // teleport them to the lowest block + Vector safe = getSafeLocationUnder(under); + EntityLocation safeLocation = event.getPlayer().getEntityLocation(); + safeLocation.setPosition(safe); + event.setTo(safeLocation); + } + } + } else { + if(!containsFrom && !containsTo) { + // they are outside of the lane + if(isIllegallyOutsideLane(laneRegion, event.getTo())) { + this.voidPlayers.add(player.getPlayerId()); + event.getPlayer().sendMessage(ChatColor.RED + PGMTranslations.t("match.lane.exit", player)); + } + } + } + } + + @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) + public void preventBlockPlaceByVoidPlayer(final BlockTransformEvent event) { + if(event instanceof PlayerBlockTransformEvent) { + event.setCancelled(this.voidPlayers.contains(((PlayerBlockTransformEvent) event).getPlayerState().getPlayerId())); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void clearLaneStatus(final ParticipantDespawnEvent event) { + this.voidPlayers.remove(event.getPlayer().getPlayerId()); + } + + private static BlockFace[] CARDINAL_DIRECTIONS = { BlockFace.NORTH, BlockFace.EAST, BlockFace.SOUTH, BlockFace.WEST }; + private static BlockFace[] DIAGONAL_DIRECTIONS = { BlockFace.NORTH_EAST, BlockFace.SOUTH_EAST, BlockFace.SOUTH_WEST, BlockFace.NORTH_WEST }; + + private static Block getAdjacentRegionBlock(Region region, Block origin) { + for(BlockFace face : ObjectArrays.concat(CARDINAL_DIRECTIONS, DIAGONAL_DIRECTIONS, BlockFace.class)) { + Block adjacent = origin.getRelative(face); + if(region.contains(BlockUtils.center(adjacent).toVector())) { + return adjacent; + } + } + return null; + } + + private static boolean isIllegalBlock(Region region, Block block) { + Block adjacent = getAdjacentRegionBlock(region, block); + return adjacent == null || Materials.isColliding(adjacent.getType()); + } + + private static boolean isIllegallyOutsideLane(Region lane, Location loc) { + Block feet = loc.getBlock(); + if(feet == null) return false; + + if(isIllegalBlock(lane, feet)) { + return true; + } + + Block head = feet.getRelative(BlockFace.UP); + if(head == null) return false; + + if(isIllegalBlock(lane, head)) { + return true; + } + + return false; + } + + private static Vector getSafeLocationUnder(Block block) { + World world = block.getWorld(); + for(int y = block.getY() - 2; y >= 0; y--) { + Block feet = world.getBlockAt(block.getX(), y, block.getZ()); + Block head = world.getBlockAt(block.getX(), y + 1, block.getZ()); + if(feet.getType() == Material.AIR && head.getType() == Material.AIR) { + return new Vector(block.getX() + 0.5, y, block.getZ() + 0.5); + } + } + return new Vector(block.getX() + 0.5, -2, block.getZ() + 0.5); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/listeners/BlockTransformListener.java b/PGM/src/main/java/tc/oc/pgm/listeners/BlockTransformListener.java new file mode 100644 index 0000000..5acae64 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/listeners/BlockTransformListener.java @@ -0,0 +1,500 @@ +package tc.oc.pgm.listeners; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.logging.Logger; +import javax.annotation.Nullable; +import javax.inject.Inject; + +import com.google.common.collect.ArrayListMultimap; +import com.google.common.collect.ListMultimap; +import org.bukkit.Material; +import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; +import org.bukkit.block.BlockState; +import org.bukkit.entity.Arrow; +import org.bukkit.entity.Player; +import org.bukkit.entity.TNTPrimed; +import org.bukkit.event.EntityAction; +import org.bukkit.event.Event; +import org.bukkit.event.EventBus; +import org.bukkit.event.EventException; +import org.bukkit.event.EventHandlerMeta; +import org.bukkit.event.EventPriority; +import org.bukkit.event.EventRegistry; +import org.bukkit.event.Listener; +import org.bukkit.event.block.Action; +import org.bukkit.event.block.BlockBreakEvent; +import org.bukkit.event.block.BlockBurnEvent; +import org.bukkit.event.block.BlockDispenseEvent; +import org.bukkit.event.block.BlockFadeEvent; +import org.bukkit.event.block.BlockFallEvent; +import org.bukkit.event.block.BlockFormEvent; +import org.bukkit.event.block.BlockFromToEvent; +import org.bukkit.event.block.BlockGrowEvent; +import org.bukkit.event.block.BlockIgniteEvent; +import org.bukkit.event.block.BlockMultiPlaceEvent; +import org.bukkit.event.block.BlockPistonEvent; +import org.bukkit.event.block.BlockPistonExtendEvent; +import org.bukkit.event.block.BlockPistonRetractEvent; +import org.bukkit.event.block.BlockPlaceEvent; +import org.bukkit.event.block.BlockSpreadEvent; +import org.bukkit.event.entity.EntityChangeBlockEvent; +import org.bukkit.event.entity.EntityExplodeEvent; +import org.bukkit.event.entity.ExplosionPrimeByEntityEvent; +import org.bukkit.event.entity.ExplosionPrimeEvent; +import org.bukkit.event.player.PlayerBucketEmptyEvent; +import org.bukkit.event.player.PlayerBucketFillEvent; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.material.PistonExtensionMaterial; +import tc.oc.commons.bukkit.util.BlockStateUtils; +import tc.oc.commons.bukkit.util.BukkitEvents; +import tc.oc.commons.bukkit.util.Materials; +import tc.oc.commons.core.inject.Proxied; +import tc.oc.commons.core.logging.Loggers; +import tc.oc.commons.core.plugin.PluginFacet; +import tc.oc.commons.core.reflect.Methods; +import tc.oc.pgm.PGM; +import tc.oc.pgm.blockdrops.BlockDropsMatchModule; +import tc.oc.pgm.events.BlockTransformEvent; +import tc.oc.pgm.events.ParticipantBlockTransformEvent; +import tc.oc.pgm.events.PlayerBlockTransformEvent; +import tc.oc.pgm.match.Match; +import tc.oc.pgm.match.MatchFinder; +import tc.oc.pgm.match.MatchManager; +import tc.oc.pgm.match.MatchPlayer; +import tc.oc.pgm.match.MatchPlayerState; +import tc.oc.pgm.match.ParticipantState; +import tc.oc.pgm.tnt.InstantTNTPlaceEvent; +import tc.oc.pgm.tracker.BlockResolver; +import tc.oc.pgm.tracker.EntityResolver; + +public class BlockTransformListener implements PluginFacet, Listener { + private static final BlockFace[] NEIGHBORS = { BlockFace.WEST, BlockFace.EAST, BlockFace.DOWN, BlockFace.UP, BlockFace.NORTH, BlockFace.SOUTH }; + + @Retention(RetentionPolicy.RUNTIME) + @interface EventWrapper {} + + private final Logger logger; + private final EventBus eventBus; + private final EventRegistry eventRegistry; + private final MatchFinder matchFinder; + private final BlockResolver blockResolver; + private final EntityResolver entityResolver; + + private final ListMultimap currentEvents = ArrayListMultimap.create(); + + @Inject BlockTransformListener(Loggers loggers, EventBus eventBus, EventRegistry eventRegistry, MatchManager matchFinder, @Proxied BlockResolver blockResolver, @Proxied EntityResolver entityResolver) { + this.logger = loggers.get(getClass()); + this.eventBus = eventBus; + this.eventRegistry = eventRegistry; + this.matchFinder = matchFinder; + this.blockResolver = blockResolver; + this.entityResolver = entityResolver; + } + + @Override + public void enable() { + // Find all the @EventWrapper methods in this class and register them at EVERY priority level. + for(final Method method : Methods.annotatedMethods(getClass(), EventWrapper.class)) { + final Class eventClass = method.getParameterTypes()[0].asSubclass(Event.class); + + for(final EventPriority priority : EventPriority.values()) { + Event.register(eventRegistry.bindHandler(new EventHandlerMeta<>(eventClass, priority, false), this, (listener, event) -> { + // Ignore events from non-match worlds + if(matchFinder.getMatch(event) == null) return; + + if(!BukkitEvents.isCancelled(event)) { + // At the first priority level, call the event handler method. + // If it decides to generate a BlockTransformEvent, it will be stored in currentEvents. + if(priority == EventPriority.LOWEST) { + if(eventClass.isInstance(event)) { + try { + method.invoke(listener, event); + } catch (InvocationTargetException ex) { + throw new EventException(ex.getCause(), event); + } catch (Throwable t) { + throw new EventException(t, event); + } + } + } + } + + // Check for cached events and dispatch them at the current priority level only. + // The BTE needs to be dispatched even after it's cancelled, because we DO have + // listeners that depend on receiving cancelled events e.g. WoolMatchModule. + for(BlockTransformEvent bte : currentEvents.get(event)) { + eventBus.callEvent(bte, priority); + } + + // After dispatching the last priority level, clean up the cached events and do post-event stuff. + // This needs to happen even if the event is cancelled. + if(priority == EventPriority.MONITOR) { + finishCauseEvent(event); + } + })); + } + } + } + + private void finishCauseEvent(Event causeEvent) { + List wrapperEvents = currentEvents.removeAll(causeEvent); + + for(BlockTransformEvent bte : wrapperEvents) { + processCancelMessage(bte); + } + + for(BlockTransformEvent bte : wrapperEvents) { + processBlockDrops(bte); + } + + // A few of the event handlers need to do some post-processing after the wrapper event returns. + if(causeEvent instanceof EntityExplodeEvent) { + finishEntityExplode((EntityExplodeEvent) causeEvent, wrapperEvents); + } else if(causeEvent instanceof BlockPistonEvent) { + finishPistonMove((BlockPistonEvent) causeEvent, wrapperEvents); + } + } + + private void callEvent(final BlockTransformEvent event) { + logger.fine("Generated event " + event); + currentEvents.put(event.getCause(), event); + } + + private @Nullable Player getPlayerActor(Event event) { + if(event instanceof EntityAction) { + final EntityAction entityAction = (EntityAction) event; + if(entityAction.getActor() instanceof Player) { + return (Player) entityAction.getActor(); + } + } + return null; + } + + private BlockTransformEvent callEvent(Event cause, BlockState oldState, BlockState newState) { + return callEvent(cause, oldState, newState, getPlayerActor(cause)); + } + + private BlockTransformEvent callEvent(Event cause, BlockState oldState, BlockState newState, @Nullable Player player) { + MatchPlayer matchPlayer = PGM.getMatchManager().getPlayer(player); + return callEvent(cause, oldState, newState, matchPlayer == null ? null : matchPlayer.playerState()); + } + + private BlockTransformEvent callEvent(Event cause, BlockState oldState, BlockState newState, @Nullable MatchPlayerState player) { + BlockTransformEvent event; + if(player == null) { + event = new BlockTransformEvent(cause, oldState, newState); + } else if(player instanceof ParticipantState) { + event = new ParticipantBlockTransformEvent(cause, oldState, newState, (ParticipantState) player); + } else { + event = new PlayerBlockTransformEvent(cause, oldState, newState, player); + } + callEvent(event); + return event; + } + + // ------------------------ + // ---- Placing blocks ---- + // ------------------------ + + @EventWrapper + public void onBlockPlace(final BlockPlaceEvent event) { + if(event instanceof BlockMultiPlaceEvent) { + for(BlockState oldState : ((BlockMultiPlaceEvent) event).getReplacedBlockStates()) { + callEvent(event, oldState, oldState.getBlock().getState(), event.getPlayer()); + } + } else { + callEvent(event, event.getBlockReplacedState(), event.getBlock().getState(), event.getPlayer()); + } + } + + @SuppressWarnings("deprecation") + @EventWrapper + public void onPlayerBucketEmpty(final PlayerBucketEmptyEvent event) { + Block block = event.getBlockClicked().getRelative(event.getBlockFace()); + Material contents = Materials.materialInBucket(event.getBucket()); + if(contents == null) { + return; + } + BlockState newBlock = BlockStateUtils.cloneWithMaterial(block, contents); + + this.callEvent(event, block.getState(), newBlock, event.getPlayer()); + } + + @EventWrapper + public void onBlockForm(final BlockGrowEvent event) { + this.callEvent(new BlockTransformEvent(event, event.getBlock().getState(), event.getNewState())); + } + + @EventWrapper + public void onBlockForm(final BlockFormEvent event) { + callEvent(event, event.getBlock().getState(), event.getNewState()); + } + + @EventWrapper + public void onBlockSpread(final BlockSpreadEvent event) { + // This fires for: fire, grass, mycelium, mushrooms, and vines + // Fire is already handled by BlockIgniteEvent + if(event.getNewState().getType() != Material.FIRE) { + this.callEvent(new BlockTransformEvent(event, event.getBlock().getState(), event.getNewState())); + } + } + + @SuppressWarnings("deprecation") + @EventWrapper + public void onBlockFromTo(BlockFromToEvent event) { + if(event.getToBlock().getType() != event.getBlock().getType()) { + BlockState oldState = event.getToBlock().getState(); + BlockState newState = event.getToBlock().getState(); + newState.setType(event.getBlock().getType()); + newState.setRawData(event.getBlock().getData()); + + // Check for lava ownership + this.callEvent(event, oldState, newState, blockResolver.getOwner(event.getBlock())); + } + } + + @EventWrapper + public void onBlockIgnite(final BlockIgniteEvent event) { + // Flint & steel generates a BlockPlaceEvent + if(event.getCause() == BlockIgniteEvent.IgniteCause.FLINT_AND_STEEL) return; + + BlockState oldState = event.getBlock().getState(); + BlockState newState = BlockStateUtils.cloneWithMaterial(event.getBlock(), Material.FIRE); + ParticipantState igniter = null; + + if(event.getIgnitingEntity() != null) { + // The player themselves using flint & steel, or any of + // several types of owned entity starting or spreading a fire. + igniter = entityResolver.getOwner(event.getIgnitingEntity()); + } else if(event.getIgnitingBlock() != null) { + // Fire, lava, or flint & steel in a dispenser + igniter = blockResolver.getOwner(event.getIgnitingBlock()); + } + + callEvent(event, oldState, newState, igniter); + } + + // ------------------------- + // ---- Breaking blocks ---- + // ------------------------- + + @EventWrapper + public void onBlockBreak(final BlockBreakEvent event) { + BlockState state = event.getBlock().getState(); + this.callEvent(event, state, BlockStateUtils.toAir(state), event.getPlayer()); + } + + @EventWrapper + public void onPlayerBucketFill(final PlayerBucketFillEvent event) { + BlockState state = event.getBlockClicked().getRelative(event.getBlockFace()).getState(); + this.callEvent(event, state, BlockStateUtils.toAir(state), event.getPlayer()); + } + + @EventWrapper + public void onPrimeTNT(ExplosionPrimeEvent event) { + if(event.getEntity() instanceof TNTPrimed && !(event instanceof InstantTNTPlaceEvent)) { + Block block = event.getEntity().getLocation().getBlock(); + if(block.getType() == Material.TNT) { + ParticipantState player; + if(event instanceof ExplosionPrimeByEntityEvent) { + player = entityResolver.getOwner(((ExplosionPrimeByEntityEvent) event).getPrimer()); + } else { + player = null; + } + callEvent(event, block.getState(), BlockStateUtils.toAir(block), player); + } + } + } + + @EventWrapper + public void onEntityExplode(final EntityExplodeEvent event) { + ParticipantState playerState = entityResolver.getOwner(event.getEntity()); + + for(Block block : event.blockList()) { + if(block.getType() != Material.TNT) { + // Don't cancel the explosion when individual blocks are cancelled + callEvent(event, block.getState(), BlockStateUtils.toAir(block), playerState).setPropagateCancel(false); + } + } + } + + private void finishEntityExplode(EntityExplodeEvent causeEvent, Collection wrapperEvents) { + // Remove blocks from the explosion if their wrapper event was cancelled + for(BlockTransformEvent wrapper : wrapperEvents) { + if(wrapper.isCancelled()) { + causeEvent.blockList().remove(wrapper.getOldState().getBlock()); + } + } + } + + @EventWrapper + public void onBlockBurn(final BlockBurnEvent event) { + Match match = PGM.getMatchManager().getMatch(event.getBlock().getWorld()); + if(match == null) return; + + BlockState oldState = event.getBlock().getState(); + BlockState newState = BlockStateUtils.toAir(oldState); + MatchPlayerState igniterState = null; + + for(BlockFace face : NEIGHBORS) { + Block neighbor = oldState.getBlock().getRelative(face); + if(neighbor.getType() == Material.FIRE) { + igniterState = blockResolver.getOwner(neighbor); + if(igniterState != null) break; + } + } + + this.callEvent(event, oldState, newState, igniterState); + } + + @EventWrapper + public void onBlockFade(final BlockFadeEvent event) { + BlockState state = event.getBlock().getState(); + this.callEvent(new BlockTransformEvent(event, state, BlockStateUtils.toAir(state))); + } + + // ----------------------- + // ---- Moving blocks ---- + // ----------------------- + + private void onPistonMove(BlockPistonEvent event, List blocks, Map newStates) { + // The block list in a piston event includes only the pushed blocks, not the empty spaces they are + // pushed into. We need to build our own map of the post-event block states. + + // Add the pushed blocks at their destination + for(Block block : blocks) { + Block dest = block.getRelative(event.getDirection()); + newStates.put(dest, BlockStateUtils.cloneWithMaterial(dest, block.getState().getData())); + } + + // Add air blocks where a block is leaving, and no other block is replacing it + for(Block block : blocks) { + if(!newStates.containsKey(block)) { + newStates.put(block, BlockStateUtils.toAir(block.getState())); + } + } + + // Fire events for all changing blocks. + for(BlockState newState : newStates.values()) { + this.callEvent(new BlockTransformEvent(event, newState.getBlock().getState(), newState)); + } + } + + private void finishPistonMove(BlockPistonEvent causeEvent, Collection wrapperEvents) { + // If ANY of the pushed block events are cancelled, the piston jams and the entire causing event is cancelled. + for(BlockTransformEvent bte : wrapperEvents) { + if(bte.isCancelled()) { + causeEvent.setCancelled(true); + break; + } + } + } + + @EventWrapper + public void onBlockPistonExtend(final BlockPistonExtendEvent event) { + Map newStates = new HashMap<>(); + + // Add the arm of the piston, which will extend into the adjacent block. + PistonExtensionMaterial pistonExtension = new PistonExtensionMaterial(Material.PISTON_EXTENSION); + pistonExtension.setFacingDirection(event.getDirection()); + BlockState pistonExtensionState = event.getBlock().getRelative(event.getDirection()).getState(); + pistonExtensionState.setType(pistonExtension.getItemType()); + pistonExtensionState.setData(pistonExtension); + newStates.put(event.getBlock(), pistonExtensionState); + + this.onPistonMove(event, event.getBlocks(), newStates); + } + + @EventWrapper + public void onBlockPistonRetract(final BlockPistonRetractEvent event) { + this.onPistonMove(event, event.getBlocks(), new HashMap()); + } + + // ----------------------------- + // ---- Transforming blocks ---- + // ----------------------------- + @EventWrapper + public void onEntityChangeBlock(final EntityChangeBlockEvent event) { + // Igniting TNT with an arrow is already handled from the ExplosionPrimeEvent + if(event.getEntity() instanceof Arrow && + event.getBlock().getType() == Material.TNT && + event.getTo() == Material.AIR) return; + + callEvent(event, event.getBlock().getState(), BlockStateUtils.cloneWithMaterial(event.getBlock(), event.getToData()), entityResolver.getOwner(event.getEntity())); + } + + @EventWrapper + public void onBlockTrample(final PlayerInteractEvent event) { + if(event.getAction() == Action.PHYSICAL) { + Block block = event.getClickedBlock(); + if(block != null) { + Material oldType = getTrampledType(block.getType()); + if(oldType != null) { + callEvent(event, BlockStateUtils.cloneWithMaterial(block, oldType), block.getState(), event.getPlayer()); + } + } + } + } + + @EventWrapper + public void onDispenserDispense(final BlockDispenseEvent event) { + if(Materials.isBucket(event.getItem())) { + // Yes, the location the dispenser is facing is stored in "velocity" for some ungodly reason + Block targetBlock = event.getVelocity().toLocation(event.getBlock().getWorld()).getBlock(); + Material contents = Materials.materialInBucket(event.getItem()); + + if(Materials.isLiquid(contents) || (contents == Material.AIR && targetBlock.isLiquid())) { + callEvent(event, targetBlock.getState(), BlockStateUtils.cloneWithMaterial(targetBlock, contents), blockResolver.getOwner(event.getBlock())); + } + } + } + + @EventWrapper + public void onBlockFall(BlockFallEvent event) { + this.callEvent(new BlockTransformEvent(event, event.getBlock().getState(), BlockStateUtils.toAir(event.getBlock().getState()))); + } + + private static Material getTrampledType(Material newType) { + switch(newType) { + case SOIL: return Material.DIRT; + default: return null; + } + } + + // -------------------------- + // ---- Event Processing ---- + // -------------------------- + + public void processCancelMessage(final BlockTransformEvent event) { + if(event instanceof PlayerBlockTransformEvent && + event.isCancelled() && + event.getCancelMessage() != null && + event.isManual()) { + + ((PlayerBlockTransformEvent) event).getPlayerState().getAudience().sendWarning(event.getCancelMessage(), false); + } + } + + public void processBlockDrops(BlockTransformEvent event) { + // If the event has been altered with custom block drops/replacement, + // call on the BlockDropsMatchModule to handle this. We do this here + // because doBlockDrops will cancel the event, and we don't want any + // other listeners to think the event is cancelled when it isn't. + if(event != null && !event.isCancelled() && event.getDrops() != null) { + Match match = PGM.getMatchManager().getMatch(event.getWorld()); + if(match != null) { + BlockDropsMatchModule bdmm = match.getMatchModule(BlockDropsMatchModule.class); + if(bdmm != null) { + bdmm.doBlockDrops(event); + } + } + } + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/listeners/FormattingListener.java b/PGM/src/main/java/tc/oc/pgm/listeners/FormattingListener.java new file mode 100644 index 0000000..be14b4e --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/listeners/FormattingListener.java @@ -0,0 +1,92 @@ +package tc.oc.pgm.listeners; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +import net.md_5.bungee.api.chat.BaseComponent; +import net.md_5.bungee.api.chat.TranslatableComponent; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import tc.oc.commons.bukkit.chat.ListComponent; +import tc.oc.commons.bukkit.chat.NameStyle; +import tc.oc.commons.bukkit.util.BukkitUtils; +import tc.oc.commons.core.chat.Component; +import tc.oc.commons.core.chat.Components; +import tc.oc.pgm.core.CoreLeakEvent; +import tc.oc.pgm.destroyable.Destroyable; +import tc.oc.pgm.destroyable.DestroyableContribution; +import tc.oc.pgm.destroyable.DestroyableDestroyedEvent; +import tc.oc.pgm.wool.PlayerWoolPlaceEvent; + +public class FormattingListener implements Listener { + @EventHandler(priority = EventPriority.MONITOR) + public void playerWoolPlace(final PlayerWoolPlaceEvent event) { + if (event.getWool().isVisible()) { + event.getMatch().sendMessage(new TranslatableComponent("match.complete.wool", + event.getPlayer().getStyledName(NameStyle.COLOR), + BukkitUtils.woolName(event.getWool().getDyeColor()), + event.getPlayer().getParty().getComponentName())); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void coreLeak(final CoreLeakEvent event) { + if (event.getCore().isVisible()) { + event.getMatch().sendMessage(new Component(new TranslatableComponent("match.complete.core", + Components.blank(), + event.getCore().getComponentName(), + event.getCore().getOwner().getComponentName()), + net.md_5.bungee.api.ChatColor.RED)); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void destroyableDestroyed(final DestroyableDestroyedEvent event) { + Destroyable destroyable = event.getDestroyable(); + + if (destroyable.isVisible()) { + List sorted = new ArrayList<>(event.getDestroyable().getContributions()); + Collections.sort(sorted, new Comparator() { + @Override + public int compare(DestroyableContribution o1, DestroyableContribution o2) { + return Double.compare(o2.getPercentage(), o1.getPercentage()); // reverse + } + }); + + List contributors = new ArrayList<>(); + boolean someExcluded = false; + for(DestroyableContribution entry : sorted) { + if(entry.getPercentage() > 0.2) { // 20% necessary to be included + contributors.add(new TranslatableComponent( + "objective.credit.player.percentage", + entry.getPlayerState().getStyledName(NameStyle.COLOR), + new Component(String.valueOf(Math.round(entry.getPercentage() * 100)), net.md_5.bungee.api.ChatColor.AQUA) + )); + } else { + someExcluded = true; + } + } + + BaseComponent credit; + if(contributors.isEmpty()) { + credit = someExcluded ? new TranslatableComponent("objective.credit.many") // All contributors < 20% + : new TranslatableComponent("objective.credit.unknown"); // No contributors + } else { + if(someExcluded) { + contributors.add(new TranslatableComponent("objective.credit.etc")); // Some contributors < 20% + } + credit = new ListComponent(contributors); + } + + event.getMatch().sendMessage(new TranslatableComponent( + "match.complete.destroyable", + credit, + destroyable.getComponentName(), + destroyable.getOwner().getComponentName() + )); + } + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/listeners/ItemTransferListener.java b/PGM/src/main/java/tc/oc/pgm/listeners/ItemTransferListener.java new file mode 100644 index 0000000..50434e9 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/listeners/ItemTransferListener.java @@ -0,0 +1,614 @@ +package tc.oc.pgm.listeners; + +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.Sound; +import org.bukkit.entity.Item; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.inventory.InventoryAction; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryClickedEvent; +import org.bukkit.event.inventory.InventoryDragEvent; +import org.bukkit.event.inventory.InventoryPickupItemEvent; +import org.bukkit.event.player.PlayerDropItemEvent; +import org.bukkit.event.player.PlayerPickupItemEvent; +import org.bukkit.inventory.*; +import tc.oc.commons.bukkit.inventory.InventorySlot; +import tc.oc.pgm.PGM; +import tc.oc.pgm.events.ItemTransferEvent; +import tc.oc.pgm.events.PlayerItemTransferEvent; + +import java.util.Map; +import java.util.Optional; + +public class ItemTransferListener implements Listener { + // Track players dropping an item stack from within an inventory GUI + private boolean ignoreNextDropEvent; + private boolean collectToCursor; + + private Inventory getLocalInventory(InventoryView view, int rawSlot) { + int cookedSlot = view.convertSlot(rawSlot); + if(cookedSlot == rawSlot) { + return view.getTopInventory(); + } else { + return view.getBottomInventory(); + } + } + + private static Inventory getOtherInventory(InventoryView view, Inventory inventory) { + if(view.getTopInventory() == inventory) { + return view.getBottomInventory(); + } else { + return view.getTopInventory(); + } + } + + private static int getQuantityPlaceable(ItemStack stack, Inventory inventory) { + int transferrable = 0; + for(ItemStack slotStack : inventory.contents()) { + if(slotStack == null) { + return stack.getAmount(); + } else if(slotStack.isSimilar(stack)) { + transferrable += stack.getMaxStackSize() - slotStack.getAmount(); + if(transferrable >= stack.getAmount()) { + return stack.getAmount(); + } + } + } + return transferrable; + } + + private void dropFromPlayer(Player player, ItemStack stack) { + Item entity = player.getWorld().dropItem(player.getEyeLocation(), stack); + entity.setVelocity(player.getLocation().getDirection().multiply(0.3)); + } + + private void callEvent(ItemTransferEvent event) { + Bukkit.getPluginManager().callEvent(event); + } + + @EventHandler + public void onPlayerPickupItem(PlayerPickupItemEvent event) { + // When this event is fired, the ItemStack in the Item being picked up is temporarily + // set to the amount that will actually be picked up, while the difference from the + // actual amount in the stack is available from getRemaining(). When the event returns, + // the original amount is restored to the stack, meaning that we can't change the amount + // from inside the event, so instead we replace the entire stack. + + int initialQuantity = event.getItem().getItemStack().getAmount(); + PlayerItemTransferEvent transferEvent = new PlayerItemTransferEvent( + event, ItemTransferEvent.Type.PICKUP, event.getPlayer(), + Optional.empty(), + Optional.of(new InventorySlot<>(event.getPlayer().getInventory())), + event.getItem().getItemStack(), event.getItem(), + initialQuantity, event.getPlayer().getOpenInventory().getCursor() + ); + + this.callEvent(transferEvent); + + int quantity = Math.min(transferEvent.getQuantity(), initialQuantity); + + if(!event.isCancelled() && quantity < initialQuantity) { + event.setCancelled(true); + if(quantity > 0) { + ItemStack stack = event.getItem().getItemStack().clone(); + stack.setAmount(stack.getAmount() - quantity); + event.getItem().setItemStack(stack); + + stack = stack.clone(); + stack.setAmount(quantity); + event.getPlayer().getInventory().addItem(stack); + event.getPlayer().playSound(event.getPlayer().getLocation(), Sound.ENTITY_ITEM_PICKUP, 1, 1); + } + } + } + + @EventHandler + public void onBlockPickupItem(InventoryPickupItemEvent event) { + int initialQuantity = getQuantityPlaceable(event.getItem().getItemStack(), event.getInventory()); + ItemTransferEvent transferEvent = new ItemTransferEvent( + event, ItemTransferEvent.Type.PICKUP, + Optional.empty(), + Optional.of(new InventorySlot<>(event.getInventory())), + event.getItem().getItemStack(), event.getItem(), initialQuantity + ); + + this.callEvent(transferEvent); + + if(initialQuantity != transferEvent.getQuantity() && !event.isCancelled()) { + event.setCancelled(true); + ItemStack stack = event.getItem().getItemStack(); + stack.setAmount(stack.getAmount() - transferEvent.getQuantity()); + stack = stack.clone(); + stack.setAmount(transferEvent.getQuantity()); + event.getInventory().addItem(stack); + } + } + + @EventHandler + public void onPlayerClickInventory(InventoryClickEvent event) { + // Ignored actions + switch(event.getAction()) { + case CLONE_STACK: // Out of scope + case COLLECT_TO_CURSOR: // Handled by InventoryClickedEvent + case NOTHING: + case UNKNOWN: + return; + } + + // Get the player who clicked + if(!(event.getWhoClicked() instanceof Player)) { + // Can this happen? + return; + } + Player player = (Player) event.getWhoClicked(); + + // In a dual-inventory view, InventoryClickEvent.getInventory() always returns the top inventory, + // so to figure out which one was actually clicked, we compare the view slot with the inv slot. + // If they are the same, the click is in the top inv, because it is always mapped to view slot 0. + // Otherwise, the click is in the bottom inv. This is the Bukkit recommended way to do this. + Inventory inventory = getLocalInventory(event.getView(), event.getRawSlot()); + + if(event.getAction() == InventoryAction.SWAP_WITH_CURSOR) { + // Click on a stack while already holding a stack, fire two events for the stacks being swapped + if(inventory.getHolder() == player) { + // Swap with own inventory is not a transfer + return; + } + boolean cancelled = event.isCancelled(); + int quantity = event.getCurrentItem().getAmount(); + + // The take event has no items on the cursor, because those will be placed by the second event + PlayerItemTransferEvent transferEvent = new PlayerItemTransferEvent( + event, ItemTransferEvent.Type.TAKE, player, + Optional.of(InventorySlot.fromEvent(event)), + Optional.empty(), + event.getCurrentItem(), null, + quantity, null + ); + this.callEvent(transferEvent); + cancelled = cancelled | event.isCancelled() | quantity != transferEvent.getQuantity(); + + // Remove the item from the inventory so handlers of the second event can see that it is gone + ItemStack oldInvStack = event.getCurrentItem(); + event.setCurrentItem(null); + + quantity = event.getCursor().getAmount(); + transferEvent = new PlayerItemTransferEvent( + event, ItemTransferEvent.Type.PLACE, player, + Optional.empty(), + Optional.of(InventorySlot.fromEvent(event)), + event.getCursor(), null, + event.getCursor().getAmount(), event.getCursor() + ); + event.setCancelled(cancelled | event.isCancelled() | quantity != transferEvent.getQuantity()); + + // Replace the old item in the inventory + event.setCurrentItem(oldInvStack); + + return; + } + + // The remaining actions will generate one event at most + ItemTransferEvent.Type type; + Inventory fromInventory = null; + Integer fromSlot = null; + Inventory toInventory = null; + Integer toSlot = null; + ItemStack itemStack; + + // Determine inv, slot, and stack + switch(event.getAction()) { + case PICKUP_ALL: + case PICKUP_SOME: + case PICKUP_HALF: + case PICKUP_ONE: + if(inventory.getHolder() == player) { + // Taking from own inventory is not a transfer + return; + } + type = ItemTransferEvent.Type.TAKE; + itemStack = event.getCurrentItem(); + fromInventory = inventory; + fromSlot = event.getSlot(); + break; + + case PLACE_ALL: + case PLACE_SOME: + case PLACE_ONE: + if(inventory.getHolder() == player) { + // Placing in own inventory is not a transfer + return; + } + type = ItemTransferEvent.Type.PLACE; + itemStack = event.getCursor(); + toInventory = inventory; + toSlot = event.getSlot(); + break; + + case DROP_ONE_SLOT: + case DROP_ALL_SLOT: + type = ItemTransferEvent.Type.DROP; + itemStack = event.getCurrentItem(); + fromInventory = inventory; + fromSlot = event.getSlot(); + break; + + case DROP_ONE_CURSOR: + case DROP_ALL_CURSOR: + type = ItemTransferEvent.Type.DROP; + itemStack = event.getCursor(); + break; + + case MOVE_TO_OTHER_INVENTORY: + itemStack = event.getCurrentItem(); + fromInventory = inventory; + fromSlot = event.getSlot(); + toInventory = getOtherInventory(event.getView(), fromInventory); + + if(toInventory == null || fromInventory.getHolder() == toInventory.getHolder()) { + // shift-click to hotbar/armor slots + return; + } + + if(fromInventory.getHolder() == player && toInventory.getHolder() != player) { + type = ItemTransferEvent.Type.PLACE; + } else if (fromInventory.getHolder() != player && toInventory.getHolder() == player) { + type = ItemTransferEvent.Type.TAKE; + } else { + type = ItemTransferEvent.Type.TRANSFER; + } + break; + + case HOTBAR_SWAP: + case HOTBAR_MOVE_AND_READD: + // Use a hotkey to move a stack in or out of a hotbar slot. If moving a stack into a + // hotbar slot that is already occupied by an incompatible stack, that stack will be + // moved to the slot under the cursor, if that slot is in the player's inventory, + // otherwise it will be moved to the first available slot in the player's inventory. + if(inventory.getHolder() == player) { + // Ignore intra-inventory swap + return; + } + if(event.getCurrentItem() != null && event.getCurrentItem().getType() != Material.AIR) { + // Moving an item onto the hotbar + type = ItemTransferEvent.Type.TAKE; + itemStack = event.getCurrentItem(); + fromInventory = inventory; + fromSlot = event.getSlot(); + toInventory = player.getInventory(); + toSlot = event.getHotbarButton(); + } else { + itemStack = player.getInventory().getItem(event.getHotbarButton()); + if(itemStack == null || itemStack.getType() == Material.AIR) { + return; + } + // Moving an item out of the hotbar + type = ItemTransferEvent.Type.PLACE; + fromInventory = player.getInventory(); + fromSlot = event.getHotbarButton(); + toInventory = inventory; + toSlot = event.getSlot(); + } + break; + + default: + PGM.get().getLogger().warning("ItemTransferListener.onPlayerClickItem: Unhandled action " + event.getAction()); + return; + } + + int initialQuantity = 0; + + // Determine quantity + switch(event.getAction()) { + case PICKUP_ALL: // left-click stack with empty cursor + case DROP_ALL_SLOT: // press control-drop key while hovering over stack + case HOTBAR_SWAP: + case HOTBAR_MOVE_AND_READD: + initialQuantity = event.getCurrentItem().getAmount(); + break; + + case PLACE_ALL: // left-click with cursor stack on empty slot or matching stack with enough space + case DROP_ALL_CURSOR: // left-click outside of window with cursor stack + initialQuantity = event.getCursor().getAmount(); + break; + + case PICKUP_SOME: // left/right-click oversized stack with empty cursor + initialQuantity = Math.min(event.getCurrentItem().getAmount(), event.getCurrentItem().getMaxStackSize()); + break; + + case PLACE_SOME: // left-click with cursor stack on undersized-slot (e.g. beacon) or matching stack without enough space + initialQuantity = Math.min(event.getCursor().getAmount(), Math.min(event.getCursor().getMaxStackSize(), + event.getInventory().getMaxStackSize())); + ItemStack existingStack = event.getCurrentItem(); + if(existingStack != null) { + initialQuantity -= existingStack.getAmount(); + } + break; + + case PICKUP_HALF: // right-click stack with empty cursor (rounds up) + initialQuantity = (event.getCurrentItem().getAmount() + 1) / 2; + break; + + case PICKUP_ONE: // same cause as PICKUP_SOME + case PLACE_ONE: // right-click with cursor stack on slot/stack with space + case DROP_ONE_CURSOR: // right-click outside of window with cursor stack + case DROP_ONE_SLOT: // press drop key while hovering over stack + initialQuantity = 1; + break; + + case MOVE_TO_OTHER_INVENTORY: // shift-click in a dual-inventory view + initialQuantity = getQuantityPlaceable(event.getCurrentItem(), toInventory); + break; + } + + if(initialQuantity <= 0) { + return; + } + + final Integer finalFromSlot = fromSlot, finalToSlot = toSlot; + PlayerItemTransferEvent transferEvent = new PlayerItemTransferEvent(event, type, player, + Optional.ofNullable(fromInventory).map(inv -> InventorySlot.fromInventoryIndex(inv, finalFromSlot)), + Optional.ofNullable(toInventory).map(inv -> InventorySlot.fromInventoryIndex(inv, finalToSlot)), + itemStack, null, initialQuantity, + event.getCursor()); + this.callEvent(transferEvent); + int quantity = Math.min(transferEvent.getQuantity(), initialQuantity); + + if(quantity < initialQuantity) { + event.setCancelled(true); + if(quantity > 0) { + ItemStack item; + ItemStack otherItem; + + switch(event.getAction()) { + case PICKUP_ALL: + case PICKUP_SOME: + case PICKUP_HALF: + case PICKUP_ONE: + item = event.getCurrentItem(); + item.setAmount(item.getAmount() - quantity); + + otherItem = item.clone(); + otherItem.setAmount(quantity); + event.getView().setCursor(otherItem); + break; + + case PLACE_ALL: + case PLACE_SOME: + case PLACE_ONE: + otherItem = event.getCursor(); + otherItem.setAmount(otherItem.getAmount() - quantity); + event.getView().setCursor(otherItem); + + item = event.getCurrentItem(); + if(item == null || item.getType() == Material.AIR) { + item = otherItem.clone(); + item.setAmount(quantity); + event.setCurrentItem(item); + PGM.get().getLogger().info("Placing " + item + " in slot " + event.getRawSlot()); + } else { + item.setAmount(item.getAmount() + quantity); + } + break; + + case DROP_ALL_CURSOR: + case DROP_ONE_CURSOR: + otherItem = event.getCursor(); + otherItem.setAmount(otherItem.getAmount() - quantity); + event.getView().setCursor(otherItem); + + item = otherItem.clone(); + item.setAmount(quantity); + this.dropFromPlayer(player, item); + break; + + case DROP_ALL_SLOT: + case DROP_ONE_SLOT: + item = event.getCurrentItem(); + item.setAmount(item.getAmount() - quantity); + + item = item.clone(); + item.setAmount(quantity); + this.dropFromPlayer(player, item); + break; + + case MOVE_TO_OTHER_INVENTORY: + if(toInventory != null) { + item = event.getCurrentItem(); + item.setAmount(item.getAmount() - quantity); + + item = item.clone(); + item.setAmount(quantity); + toInventory.addItem(item); + } + break; + + case HOTBAR_SWAP: + case HOTBAR_MOVE_AND_READD: + otherItem = player.getInventory().getItem(event.getHotbarButton()); + + item = event.getCurrentItem(); + if(item != null && item.getType() != Material.AIR) { + // Move item onto hotbar + item.setAmount(item.getAmount() - quantity); + + item = item.clone(); + item.setAmount(quantity); + player.getInventory().setItem(event.getHotbarButton(), item); + + if(otherItem != null) { + player.getInventory().addItem(otherItem); + } + } else if(otherItem != null && otherItem.getType() != Material.AIR) { + // Move item off of hotbar + otherItem.setAmount(otherItem.getAmount() - quantity); + otherItem = otherItem.clone(); + otherItem.setAmount(quantity); + event.setCurrentItem(otherItem); + } + break; + } + } + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void setIgnoreDropFlag(InventoryClickEvent event) { + switch(event.getAction()) { + case DROP_ALL_CURSOR: + case DROP_ONE_CURSOR: + case DROP_ALL_SLOT: + case DROP_ONE_SLOT: + // Make a note to ignore the PlayerDropItemEvent that will follow this one + this.ignoreNextDropEvent = true; + break; + } + } + + @EventHandler + public void onPlayerDropItem(PlayerDropItemEvent event) { + if(this.ignoreNextDropEvent) { + this.ignoreNextDropEvent = false; + } else { + // If the ignore flag is clear, this drop was caused by something other than + // an inventory click (e.g. drop key, death, etc), so an event has not yet been fired + int initialQuantity = event.getItemDrop().getItemStack().getAmount(); + ItemStack stack = event.getItemDrop().getItemStack(); + PlayerItemTransferEvent transferEvent = new PlayerItemTransferEvent(event, ItemTransferEvent.Type.DROP, event.getPlayer(), + Optional.of(new InventorySlot(event.getPlayer().getInventory())), + Optional.empty(), + stack, event.getItemDrop(), initialQuantity, + event.getPlayer().getOpenInventory().getCursor()); + this.callEvent(transferEvent); + + if(!transferEvent.isCancelled() && transferEvent.getQuantity() < initialQuantity) { + int diff = initialQuantity - transferEvent.getQuantity(); + stack.setAmount(stack.getAmount() - diff); + stack = stack.clone(); + stack.setAmount(diff); + event.getPlayer().getInventory().addItem(stack); + } + } + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void collectToCursor(InventoryClickEvent event) { + // If this hasn't been cancelled yet, cancel it so our implementation can take over + if(event.getAction() == InventoryAction.COLLECT_TO_CURSOR) { + event.setCancelled(true); + this.collectToCursor = true; + } + } + + @EventHandler + public void onPlayerInventoryClicked(InventoryClickedEvent event) { + // Control-double-click on a stack, all similar stacks are moved to the cursor, up to the max stack size + // We cancel all of these and redo them ourselves. We have to do it from a InventoryClickedEvent because + // we can't make the necessary changes from inside a InventoryClickEvent. + if(this.collectToCursor) { + this.collectToCursor = false; + + if(!(event.getWhoClicked() instanceof Player)) { + return; + } + Player player = (Player) event.getWhoClicked(); + + ItemStack cursor = event.getCursor().clone(); + + for(int pass = 0; pass < 2; pass++) { + for(int rawSlot = 0; rawSlot < event.getView().countSlots(); rawSlot++) { + if(cursor.getAmount() >= cursor.getMaxStackSize()) { + // If the gathered stack is full, we're done + break; + } + + ItemStack stack = event.getView().getItem(rawSlot); + // First pass takes incomplete stacks, second pass takes complete ones + if(cursor.isSimilar(stack) && ((pass == 0 && stack.getAmount() < stack.getMaxStackSize()) || + (pass == 1 && stack.getAmount() >= stack.getMaxStackSize()))) { + // Calculate how much can be collected from this stack + // If it is the output slot of a transaction preview, 0 + int quantity = + event.getView().getTopInventory() instanceof CraftingInventory && event.getView().convertSlot(rawSlot) == 0 || + event.getView().getTopInventory() instanceof MerchantInventory && event.getView().convertSlot(rawSlot) == 2 + ? 0 + : Math.min(stack.getAmount(), cursor.getMaxStackSize() - cursor.getAmount()); + Inventory localInventory = getLocalInventory(event.getView(), rawSlot); + if(localInventory.getHolder() != player) { + // If stack comes from an external inventory, fire a transfer event + PlayerItemTransferEvent transferEvent = new PlayerItemTransferEvent(event, ItemTransferEvent.Type.TAKE, player, + Optional.of(InventorySlot.fromInventoryIndex(localInventory, event.getView().convertSlot(rawSlot))), + Optional.empty(), + stack, null, quantity, cursor); + this.callEvent(transferEvent); + if(transferEvent.isCancelled()) { + // If the event is cancelled, don't transfer from this slot + quantity = 0; + } else { + quantity = transferEvent.getQuantity(); + } + } + + if(quantity > 0) { + // Collect items from this stack to the cursor + cursor.setAmount(cursor.getAmount() + quantity); + if(quantity == stack.getAmount()) { + event.getView().setItem(rawSlot, null); + } else { + stack.setAmount(stack.getAmount() - quantity); + } + } + } + } + } + + event.getView().setCursor(cursor); + player.updateInventory(); + } + } + + @EventHandler(ignoreCancelled = true) + public void onPlayerDragInventory(InventoryDragEvent event) { + // This is when you spread items evenly across slots by dragging + if(!(event.getWhoClicked() instanceof Player)) { + return; + } + Player player = (Player) event.getWhoClicked(); + + ItemStack transferred = event.getOldCursor().clone(); + transferred.setAmount(0); + Inventory externalInventory = null; + + for(Map.Entry entry : event.getNewItems().entrySet()) { + Inventory inventory = getLocalInventory(event.getView(), entry.getKey()); + if(inventory.getHolder() != player) { + // Add stacks to the total if they are dragged over an external inventory + externalInventory = inventory; + transferred.setAmount(transferred.getAmount() + entry.getValue().getAmount()); + } + } + + if(externalInventory != null) { + int initialQuantity = transferred.getAmount(); + PlayerItemTransferEvent transferEvent = new PlayerItemTransferEvent( + event, ItemTransferEvent.Type.PLACE, player, + Optional.empty(), + Optional.of(new InventorySlot<>(externalInventory)), + transferred, null, initialQuantity, + event.getOldCursor() + ); + + this.callEvent(transferEvent); + + if(initialQuantity != transferEvent.getQuantity()) { + // If the quantity changes, we have to cancel the entire drag, + // because bukkit does not let us modify the dragged stacks. + event.setCancelled(true); + } + } + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/listeners/MatchAnnouncer.java b/PGM/src/main/java/tc/oc/pgm/listeners/MatchAnnouncer.java new file mode 100644 index 0000000..9f16bb9 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/listeners/MatchAnnouncer.java @@ -0,0 +1,186 @@ +package tc.oc.pgm.listeners; + +import java.util.List; +import java.util.Set; +import javax.inject.Inject; +import javax.inject.Singleton; + +import com.google.common.collect.Collections2; +import com.google.common.collect.Iterables; +import com.google.common.collect.Lists; +import net.md_5.bungee.api.ChatColor; +import net.md_5.bungee.api.chat.BaseComponent; +import net.md_5.bungee.api.chat.TranslatableComponent; +import org.bukkit.Sound; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import tc.oc.api.docs.PlayerId; +import tc.oc.commons.bukkit.chat.BukkitSound; +import tc.oc.commons.bukkit.chat.HeaderComponent; +import tc.oc.commons.bukkit.chat.ListComponent; +import tc.oc.commons.bukkit.chat.NameStyle; +import tc.oc.commons.core.chat.Component; +import tc.oc.commons.core.chat.Components; +import tc.oc.commons.core.plugin.PluginFacet; +import tc.oc.pgm.Config; +import tc.oc.pgm.events.MatchBeginEvent; +import tc.oc.pgm.events.MatchEndEvent; +import tc.oc.pgm.events.PlayerJoinMatchEvent; +import tc.oc.pgm.events.PlayerPartyChangeEvent; +import tc.oc.pgm.map.Contributor; +import tc.oc.pgm.map.MapInfo; +import tc.oc.pgm.match.Competitor; +import tc.oc.pgm.match.Match; +import tc.oc.pgm.match.MatchFormatter; +import tc.oc.pgm.match.MatchPlayer; +import tc.oc.pgm.match.MatchScope; +import tc.oc.pgm.mutation.Mutation; +import tc.oc.pgm.mutation.MutationMatchModule; +import tc.oc.pgm.quota.QuotaMatchModule; +import tc.oc.pgm.skillreq.SkillRequirementMatchModule; +import tc.oc.pgm.teams.Team; +import tc.oc.pgm.victory.VictoryMatchModule; + +@Singleton +public class MatchAnnouncer implements PluginFacet, Listener { + + public static final Component GO = new Component(new TranslatableComponent("broadcast.go"), ChatColor.GREEN); + + private static final BukkitSound SOUND_MATCH_START = new BukkitSound(Sound.BLOCK_NOTE_PLING, 1f, 1.59f); + private static final BukkitSound SOUND_MATCH_WIN = new BukkitSound(Sound.ENTITY_WITHER_DEATH, 1f, 1f); + private static final BukkitSound SOUND_MATCH_LOSE = new BukkitSound(Sound.ENTITY_WITHER_SPAWN, 1f, 1f); + + private static final int CHAT_WIDTH = 200; + private static final int TITLE_FADE = 5; + private static final int TITLE_STAY = 100; + private static final int MAX_TITLE_WINNERS = 3; + + private final MatchFormatter formatter; + + @Inject MatchAnnouncer(MatchFormatter formatter) { + this.formatter = formatter; + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onMatchBegin(final MatchBeginEvent event) { + Match match = event.getMatch(); + match.sendMessage(new Component(new TranslatableComponent("broadcast.matchStart"), ChatColor.GREEN)); + + for(MatchPlayer player : match.getParticipatingPlayers()) { + player.showTitle(GO, null, 0, 5, 15); + } + + match.playSound(SOUND_MATCH_START); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onMatchEnd(final MatchEndEvent event) { + final VictoryMatchModule vmm = event.getMatch().needMatchModule(VictoryMatchModule.class); + final Set winners = vmm.winners(); + final BaseComponent chat, title; + + if(!winners.isEmpty()) { + final boolean plural = winners.size() > 1 || Iterables.getOnlyElement(winners).isNamePlural(); + chat = new TranslatableComponent(plural ? "broadcast.gameOver.teamWinText.plural" + : "broadcast.gameOver.teamWinText", + new ListComponent(winners, party -> party.getStyledName(NameStyle.FANCY))); + title = winners.size() <= MAX_TITLE_WINNERS ? chat + : new TranslatableComponent("broadcast.gameOver.multipleWinners", + new Component(winners.size(), ChatColor.AQUA)); + } else { + chat = title = new TranslatableComponent("broadcast.gameOver.gameOverText"); + } + + event.getMatch().sendMessage(chat); + + for(MatchPlayer viewer : event.getMatch().getPlayers()) { + BaseComponent subtitle = null; + if(!winners.isEmpty()) { + if(!viewer.isParticipatingType()) { + // Observer + viewer.playSound(SOUND_MATCH_WIN); + } else if(winners.contains(viewer.getCompetitor())) { + // Winner + viewer.playSound(SOUND_MATCH_WIN); + if(viewer.getParty() instanceof Team) { + subtitle = new Component(new TranslatableComponent("broadcast.gameOver.teamWon"), ChatColor.GREEN); + } + } else { + // Loser + viewer.playSound(SOUND_MATCH_LOSE); + if(viewer.getParty() instanceof Team) { + subtitle = new Component(new TranslatableComponent("broadcast.gameOver.teamLost"), ChatColor.RED); + } + } + } + + viewer.showTitle(title, subtitle, 0, 40, 40); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void join(PlayerJoinMatchEvent event) { + event.getPlayer().getBukkit().hideTitle(); + + final Match match = event.getMatch(); + final PlayerId viewerId = event.getPlayer().getPlayerId(); + + match.getScheduler(MatchScope.LOADED).createDelayedTask(5L, () -> { + final MatchPlayer viewer = match.getPlayer(viewerId); + if(viewer == null) return; + + sendWelcomeMessage(viewer); + match.module(SkillRequirementMatchModule.class).ifPresent(srmm -> srmm.sendFeedback(viewer)); + match.module(QuotaMatchModule.class).ifPresent(qmm -> qmm.sendQuotaInfo(viewer)); + }); + } + + public void sendWelcomeMessage(MatchPlayer viewer) { + MapInfo mapInfo = viewer.getMatch().getMapInfo(); + final Component name = new Component(mapInfo.name, ChatColor.BOLD, ChatColor.AQUA); + final Component objective = new Component(mapInfo.objective, ChatColor.BLUE, ChatColor.ITALIC); + + if(Config.Broadcast.title()) { + viewer.getBukkit().showTitle(name, objective, TITLE_FADE, TITLE_STAY, TITLE_FADE); + } + + viewer.sendMessage(new HeaderComponent(ChatColor.WHITE, CHAT_WIDTH, name)); + + for(BaseComponent line : Components.wordWrap(objective, CHAT_WIDTH)) { + viewer.sendMessage(line); + } + + final List authors = mapInfo.getNamedAuthors(); + if(!authors.isEmpty()) { + viewer.sendMessage( + new Component(" ", ChatColor.DARK_GRAY).extra( + new TranslatableComponent( + "broadcast.welcomeMessage.createdBy", + new ListComponent(Lists.transform(authors, author -> author.getStyledName(NameStyle.MAPMAKER))) + ) + ) + ); + } + + final MutationMatchModule mmm = viewer.getMatch().getMatchModule(MutationMatchModule.class); + if(mmm != null && mmm.getActiveMutations().size() > 0) { + viewer.sendMessage( + new Component(" ", ChatColor.DARK_GRAY).extra( + new TranslatableComponent("broadcast.welcomeMessage.mutations", + new ListComponent(Collections2.transform(mmm.getActiveMutations(), Mutation.toComponent(ChatColor.GREEN))) + ) + ) + ); + } + + viewer.sendMessage(new HeaderComponent(ChatColor.WHITE, CHAT_WIDTH)); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void matchInfoOnParticipate(final PlayerPartyChangeEvent event) { + if(event.getNewParty() instanceof Competitor) { + formatter.sendMatchInfo(event.getPlayer().getBukkit(), event.getMatch()); + } + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/listeners/PGMListener.java b/PGM/src/main/java/tc/oc/pgm/listeners/PGMListener.java new file mode 100644 index 0000000..8a8f29d --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/listeners/PGMListener.java @@ -0,0 +1,137 @@ +package tc.oc.pgm.listeners; + +import java.util.logging.Logger; +import javax.inject.Inject; + +import org.bukkit.Material; +import org.bukkit.entity.EnderPearl; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Item; +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.PlayerDeathEvent; +import org.bukkit.event.player.PlayerFishEvent; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerPickupItemEvent; +import org.bukkit.event.player.PlayerRespawnEvent; +import org.bukkit.inventory.ItemStack; +import tc.oc.commons.bukkit.localization.Translations; +import tc.oc.commons.core.logging.Loggers; +import tc.oc.commons.core.plugin.PluginFacet; +import tc.oc.pgm.Config; +import tc.oc.pgm.events.MatchBeginEvent; +import tc.oc.pgm.events.MatchEndEvent; +import tc.oc.pgm.events.MatchLoadEvent; +import tc.oc.pgm.gamerules.GameRule; +import tc.oc.pgm.gamerules.GameRulesModule; +import tc.oc.pgm.match.MatchManager; +import tc.oc.pgm.modules.TimeLockModule; + +/** + * TODO: Break this down into more specific responsibilities + */ +public class PGMListener implements PluginFacet, Listener { + + private final Logger logger; + private final MatchManager mm; + + @Inject PGMListener(Loggers loggers, MatchManager mm) { + this.logger = loggers.get(getClass()); + this.mm = mm; + } + + @EventHandler(priority = EventPriority.MONITOR) + public void kickAbandonedPlayers(final PlayerJoinEvent event) { + // Spawn module should add player to a match at a lower priority. + // If that hasn't happened for some reason, kick the player. + if(mm.getPlayer(event.getPlayer()) == null) { + event.getPlayer().kickPlayer(net.md_5.bungee.api.ChatColor.RED + Translations.get().t("incorrectWorld.kickMessage", event.getPlayer())); + logger.severe("Kicking " + event.getPlayer().getName() + " because they failed to join a match"); + } + } + + @EventHandler(ignoreCancelled = true) + public void protect36(final PlayerInteractEvent event) { + if(event.getClickedBlock() != null) { + if(event.getClickedBlock().getType() == Material.PISTON_MOVING_PIECE) { + event.setCancelled(true); + } + } + } + + // sometimes arrows stuck in players persist through deaths + @EventHandler + public void fixStuckArrows(final PlayerRespawnEvent event) { + event.getPlayer().setArrowsStuck(0); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void clearActiveEnderPearls(final PlayerDeathEvent event) { + for(Entity entity : event.getEntity().getWorld().getEntitiesByClass(EnderPearl.class)) { + if(((EnderPearl) entity).getShooter() == event.getEntity()) { + entity.remove(); + } + } + } + + // fix item pickup to work the way it should + @EventHandler(priority = EventPriority.HIGHEST) + public void handleItemPickup(final PlayerPickupItemEvent event) { + Player nearestPlayer = event.getPlayer(); + double closestDistance = event.getItem().getLocation().distance(event.getPlayer().getLocation()); + + for(Entity nearEntity : event.getItem().getNearbyEntities(1.5, 1.5, 1.5)) { + double distance = event.getItem().getLocation().distanceSquared(nearEntity.getLocation()); + + if(nearEntity instanceof Player && distance < closestDistance) { + nearestPlayer = (Player) nearEntity; + closestDistance = distance; + } + } + + if(nearestPlayer != event.getPlayer()) event.setCancelled(true); + } + + // + // Time Lock + // lock time before, during (if time lock enabled), and after the match + // + @EventHandler + public void lockTime(final MatchLoadEvent event) { + event.getMatch().getWorld().setGameRuleValue(GameRule.DO_DAYLIGHT_CYCLE.getValue(), Boolean.toString(false)); + } + + @EventHandler + public void unlockTime(final MatchBeginEvent event) { + boolean unlockTime = false; + if(!event.getMatch().getModuleContext().getModule(TimeLockModule.class).isTimeLocked()) { + unlockTime = true; + } + + GameRulesModule gameRulesModule = event.getMatch().getModuleContext().getModule(GameRulesModule.class); + + if (gameRulesModule != null && gameRulesModule.getGameRules().containsKey(GameRule.DO_DAYLIGHT_CYCLE)) { + unlockTime = gameRulesModule.getGameRules().get(GameRule.DO_DAYLIGHT_CYCLE); + } + + event.getMatch().getWorld().setGameRuleValue(GameRule.DO_DAYLIGHT_CYCLE.getValue(), Boolean.toString(unlockTime)); + } + + @EventHandler + public void lockTime(final MatchEndEvent event) { + event.getMatch().getWorld().setGameRuleValue(GameRule.DO_DAYLIGHT_CYCLE.getValue(), Boolean.toString(false)); + } + + @EventHandler + public void nerfFishing(PlayerFishEvent event) { + if (Config.Fishing.disableTreasure() && event.getCaught() instanceof Item) { + Item caught = (Item) event.getCaught(); + if (caught.getItemStack().getType() != Material.RAW_FISH) { + caught.setItemStack(new ItemStack(Material.RAW_FISH)); + } + } + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/listeners/WorldProblemMatchModule.java b/PGM/src/main/java/tc/oc/pgm/listeners/WorldProblemMatchModule.java new file mode 100644 index 0000000..8665339 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/listeners/WorldProblemMatchModule.java @@ -0,0 +1,117 @@ +package tc.oc.pgm.listeners; + +import java.util.HashSet; +import java.util.Set; +import javax.annotation.Nullable; +import javax.inject.Inject; + +import org.bukkit.Bukkit; +import org.bukkit.ChatColor; +import org.bukkit.Chunk; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; +import org.bukkit.block.BlockState; +import org.bukkit.block.Skull; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.world.ChunkLoadEvent; +import tc.oc.api.util.Permissions; +import tc.oc.commons.bukkit.util.BlockVectorSet; +import tc.oc.commons.bukkit.util.ChunkPosition; +import tc.oc.commons.bukkit.util.NMSHacks; +import tc.oc.pgm.events.ListenerScope; +import tc.oc.pgm.match.Match; +import tc.oc.pgm.match.MatchModule; +import tc.oc.pgm.match.MatchScope; + +@ListenerScope(MatchScope.LOADED) +public class WorldProblemMatchModule extends MatchModule implements Listener { + + private static final int RANDOM_TICK_SPEED_LIMIT = 30; + + private final Set repairedChunks = new HashSet<>(); + private final BlockVectorSet block36Locations = new BlockVectorSet(); + + private @Inject World world; + + @Inject WorldProblemMatchModule(Match match) { + super(match); + } + + void broadcastDeveloperWarning(String message) { + logger.warning(message); + Bukkit.broadcast(ChatColor.RED + message, Permissions.MAPERRORS); + } + + @Override + public void load() { + super.load(); + + final String str = world.getGameRuleValue("randomTickSpeed"); + if(str != null) { + try { + int value = Integer.parseInt(str); + if(value > RANDOM_TICK_SPEED_LIMIT) { + broadcastDeveloperWarning("Gamerule 'randomTickSpeed' is set to " + value + " for this world (normal value is 3). This may overload the server."); + } + } catch(NumberFormatException ignored) {} + } + + for(Chunk chunk : world.getLoadedChunks()) { + checkChunk(chunk); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onChunkLoad(ChunkLoadEvent event) { + if(world.equals(event.getWorld())) { + checkChunk(event.getChunk()); + } + } + + private void checkChunk(Chunk chunk) { + checkChunk(ChunkPosition.of(chunk), chunk); + } + + private void checkChunk(ChunkPosition pos, @Nullable Chunk chunk) { + if(repairedChunks.add(pos)) { + if(chunk == null) { + chunk = pos.getChunk(match.getWorld()); + } + + for(BlockState state : chunk.getTileEntities()) { + if(state instanceof Skull) { + if(!NMSHacks.isSkullCached((Skull) state)) { + Location loc = state.getLocation(); + broadcastDeveloperWarning("Uncached skull \"" + ((Skull) state).getOwner() + "\" at " + loc.getBlockX() + ", " + loc.getBlockY() + ", " + loc.getBlockZ()); + } + } + } + + // Replace formerly invisible half-iron-door blocks with barriers + for(Block ironDoor : chunk.getBlocks(Material.IRON_DOOR_BLOCK)) { + BlockFace half = (ironDoor.getData() & 8) == 0 ? BlockFace.DOWN : BlockFace.UP; + if(ironDoor.getRelative(half.getOppositeFace()).getType() != Material.IRON_DOOR_BLOCK) { + ironDoor.setType(Material.BARRIER, false); + } + } + + // Remove all block 36 and remember the ones at y=0 so VoidFilter can check them + for(Block block36 : chunk.getBlocks(Material.PISTON_MOVING_PIECE)) { + if(block36.getY() == 0) { + block36Locations.add(block36.getX(), block36.getY(), block36.getZ()); + } + block36.setType(Material.AIR, false); + } + } + } + + public boolean wasBlock36(int x, int y, int z) { + checkChunk(ChunkPosition.ofBlock(x, y, z), null); + return block36Locations.contains(x, y, z); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/logging/MapFilter.java b/PGM/src/main/java/tc/oc/pgm/logging/MapFilter.java new file mode 100644 index 0000000..af7997b --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/logging/MapFilter.java @@ -0,0 +1,26 @@ +package tc.oc.pgm.logging; + +import tc.oc.commons.core.logging.Logging; +import tc.oc.pgm.map.*; + +import java.util.logging.Filter; +import java.util.logging.LogRecord; + +/** + * Include or exclude log {@link LogRecord}s with a {@link PGMMap} object + * as a parameter. These are created by {@link tc.oc.pgm.map.MapLogger} for XML errors. + */ +public class MapFilter implements Filter { + + private final boolean yes; + + public MapFilter(boolean yes) { + this.yes = yes; + } + + @Override + public boolean isLoggable(LogRecord record) { + PGMMap map = Logging.getParam(record, PGMMap.class); + return (yes && map != null) || (!yes && map == null); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/logging/MapTagger.java b/PGM/src/main/java/tc/oc/pgm/logging/MapTagger.java new file mode 100644 index 0000000..a8603f1 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/logging/MapTagger.java @@ -0,0 +1,52 @@ +package tc.oc.pgm.logging; + +import net.kencochrane.raven.event.EventBuilder; +import tc.oc.minecraft.logging.BetterRaven; +import tc.oc.pgm.map.MapDefinition; +import tc.oc.pgm.map.MapLogRecord; +import tc.oc.pgm.match.Match; +import tc.oc.pgm.map.PGMMap; +import tc.oc.pgm.match.MatchManager; + +import java.util.HashSet; +import java.util.Set; +import java.util.logging.LogRecord; +import java.util.stream.Collectors; + +public class MapTagger implements BetterRaven.Helper { + + private final MatchManager matchManager; + + public MapTagger(MatchManager matchManager) { + this.matchManager = matchManager; + } + + @Override + public void helpBuildingEvent(EventBuilder eventBuilder, LogRecord record) { + if(record == null) return; + + // During match cycle, multiple matches (and maps) can be loaded, + // and that is a popular time for errors to happen. + Set maps = new HashSet<>(); + + if(record instanceof MapLogRecord) { + MapLogRecord mapRecord = (MapLogRecord) record; + maps.add(mapRecord.getMap()); + eventBuilder.setCulprit(mapRecord.getLocation()); + } + + if(matchManager != null) { + maps.addAll(matchManager.currentMatches().stream().map(Match::getMap).collect(Collectors.toList())); + } + + int i = 0; + for(MapDefinition map : maps) { + String suffix = maps.size() == 1 ? "" : String.valueOf(i); + eventBuilder.addTag("pgm_map_path" + suffix, map.getFolder().getAbsolutePath().toString()); + eventBuilder.addTag("pgm_map_name" + suffix, map.getName()); + if(map instanceof PGMMap && map.isLoaded()) { + eventBuilder.addTag("pgm_map_version" + suffix, ((PGMMap) map).getInfo().version.toString()); + } + } + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/loot/Cache.java b/PGM/src/main/java/tc/oc/pgm/loot/Cache.java new file mode 100644 index 0000000..578dc3c --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/loot/Cache.java @@ -0,0 +1,21 @@ +package tc.oc.pgm.loot; + +import tc.oc.pgm.features.FeatureDefinition; +import tc.oc.pgm.features.FeatureInfo; +import tc.oc.pgm.filters.Filter; +import tc.oc.pgm.filters.matcher.StaticFilter; +import tc.oc.pgm.regions.BlockBoundedValidation; +import tc.oc.pgm.regions.Region; + +@FeatureInfo(name = "cache", plural = "lootables", singular = "cache") +public interface Cache extends FeatureDefinition { + + @Property + @Validate(BlockBoundedValidation.class) + Region region(); + + @Property + default Filter filter() { + return StaticFilter.ALLOW; + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/loot/FillListener.java b/PGM/src/main/java/tc/oc/pgm/loot/FillListener.java new file mode 100644 index 0000000..e25c786 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/loot/FillListener.java @@ -0,0 +1,258 @@ +package tc.oc.pgm.loot; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Predicate; +import java.util.logging.Logger; +import java.util.stream.Stream; +import javax.annotation.Nullable; +import javax.inject.Inject; + +import com.google.common.collect.HashMultimap; +import com.google.common.collect.SetMultimap; +import org.bukkit.World; +import org.bukkit.block.BlockState; +import org.bukkit.block.Chest; +import org.bukkit.block.DoubleChest; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.inventory.InventoryOpenEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import tc.oc.commons.bukkit.inventory.InventorySlot; +import tc.oc.commons.core.ListUtils; +import tc.oc.commons.core.collection.InstantMap; +import tc.oc.commons.core.logging.Loggers; +import tc.oc.commons.core.random.AdvancingEntropy; +import tc.oc.commons.core.random.Entropy; +import tc.oc.commons.core.stream.Collectors; +import tc.oc.commons.core.util.Pair; +import tc.oc.pgm.filters.Filter; +import tc.oc.pgm.filters.FilterDispatcher; +import tc.oc.pgm.filters.query.EntityQuery; +import tc.oc.pgm.filters.query.ITransientQuery; +import tc.oc.pgm.filters.query.TransientPlayerQuery; +import tc.oc.pgm.itemmeta.ItemModifier; +import tc.oc.pgm.match.Match; +import tc.oc.pgm.match.MatchPlayer; +import tc.oc.pgm.match.MatchPlayerFinder; +import tc.oc.pgm.time.WorldTickClock; + +public class FillListener implements Listener { + + private final Logger logger; + private final World world; + private final MatchPlayerFinder playerFinder; + private final ItemModifier itemModifier; + private final List fillers; + private final List caches; + + private final InstantMap> filledAt; + + @Inject private FillListener(Loggers loggers, World world, WorldTickClock clock, MatchPlayerFinder playerFinder, ItemModifier itemModifier, FilterDispatcher filterDispatcher, List fillers, List caches) { + this.logger = loggers.get(getClass()); + this.fillers = fillers; + this.playerFinder = playerFinder; + this.world = world; + this.caches = caches; + this.itemModifier = itemModifier; + this.filledAt = new InstantMap<>(clock); + + fillers.forEach(filler -> { + filler.refill_trigger().ifPresent(trigger -> { + filterDispatcher.onRise(Match.class, trigger, match -> { + filledAt.keySet().removeIf(fill -> filler.equals(fill.second)); + }); + }); + }); + } + + private static boolean isFillable(BlockState block) { + return block instanceof InventoryHolder; + } + + private static boolean isFillable(Entity entity) { + return entity instanceof InventoryHolder && !(entity instanceof Player); + } + + /** + * Return a predicate that applies a Filter to the given InventoryHolder, + * or null if the InventoryHolder is not something that we should be filling. + */ + private static @Nullable Predicate passesFilter(InventoryHolder holder) { + if(holder instanceof DoubleChest) { + final DoubleChest doubleChest = (DoubleChest) holder; + return filter -> !filter.denies((Chest) doubleChest.getLeftSide()) || + !filter.denies((Chest) doubleChest.getRightSide()); + } else if(holder instanceof BlockState) { + return filter -> !filter.denies((BlockState) holder); + } else if(holder instanceof Player) { + // This happens with crafting inventories, and possibly other transient inventory types + // Pretty sure we never want to fill an inventory held by the player + return null; + } else if(holder instanceof Entity) { + return filter -> !filter.denies(new EntityQuery((Entity) holder)); + } else { + // If we're not sure what it is, don't fill it + return null; + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onInventoryOpen(InventoryOpenEvent event) { + final MatchPlayer opener = playerFinder.getParticipant(event.getActor()); + if(opener == null) return; + + final Inventory inventory = event.getInventory(); + final Predicate passesFilter = passesFilter(inventory.getHolder()); + if(passesFilter == null) return; + + logger.fine(() -> opener.getName() + " opened a " + inventory.getHolder().getClass().getSimpleName()); + + // Find all Fillers that apply to the holder of the opened inventory + final List fillers = this.fillers.stream() + .filter(filler -> passesFilter.test(filler.filter())) + .collect(Collectors.toImmutableList()); + if(fillers.isEmpty()) return; + + logger.fine(() -> "Found fillers " + fillers.stream() + .map(Filler::identify) + .collect(java.util.stream.Collectors.joining(", "))); + + // Find all Caches that the opened inventory is part of + final List fillables = new ArrayList<>(); + for(Cache cache : caches) { + if(passesFilter.test(cache.region()) && passesFilter.test(cache.filter())) { + fillables.add(new FillableCache(cache)); + } + } + // If the inventory is not in any Cache, just fill it directly + if(fillables.isEmpty()) { + fillables.add(new FillableInventory(inventory)); + } + + fillables.forEach(fillable -> fillable.fill(opener, fillers)); + } + + private abstract class Fillable { + + abstract Stream inventories(); + + void fill(MatchPlayer opener, List fillers) { + // Build a short list of Fillers that are NOT cooling down from a previous fill + final List coolFillers = ListUtils.filteredCopyOf(fillers, (Filler filler) -> + null == filledAt.putUnlessNewer(Pair.of(this, filler), filler.refill_interval()) + ); + + // Find all the Inventories for this Fillable, and build a map of Fillers to the subset + // of Inventories that they are allowed to fill, based on the filter of each Filler. + // Note how duplicate inventories are skipped. + final SetMultimap fillerInventories = HashMultimap.create(); + inventories().distinct().forEach(inventory -> { + final Predicate passes = passesFilter(inventory.getHolder()); + for(Filler filler : coolFillers) { + if(passes.test(filler.filter())) { + fillerInventories.put(filler, inventory); + } + } + }); + + // Do all clearing before we start filling anything + fillerInventories.asMap().forEach((filler, inventories) -> { + if(filler.refill_clear()) { + inventories().forEach(Inventory::clear); + } + }); + + // Some things we will need to generate the loot + final ITransientQuery query = new TransientPlayerQuery(opener); + final Entropy entropy = new AdvancingEntropy(query.entropy().randomLong()); + + fillerInventories.asMap().forEach((filler, inventories) -> { + // For each Fillter, build a mutable list of slots that it can fill + final List slots = new ArrayList<>(); + inventories.forEach(inv -> { + for(int index = 0; index < inv.getSize(); index++) { + if(inv.getItem(index) == null) { + slots.add(InventorySlot.fromInventoryIndex(inv, index)); + } + } + }); + + if(!slots.isEmpty()) { + // Generate the loot for this Filler + filler.loot().items().elements(query).forEachOrdered(item -> { + if(!slots.isEmpty()) { + // For each item, remove a random slot from those remaining, + // apply item mods, and put it in the slot. + entropy.removeRandomElement(slots) + .putItem(itemModifier.modifyCopy(item)); + } + }); + } + }); + } + } + + private class FillableInventory extends Fillable { + final Inventory inventory; + + FillableInventory(Inventory inventory) { + this.inventory = inventory; + } + + @Override + public int hashCode() { + return inventory.hashCode(); + } + + @Override + public boolean equals(Object obj) { + return obj instanceof FillableInventory && + inventory.equals(((FillableInventory) obj).inventory); + } + + @Override + Stream inventories() { + return Stream.of(inventory); + } + } + + private class FillableCache extends Fillable { + final Cache cache; + + private FillableCache(Cache cache) { + this.cache = cache; + } + + @Override + public int hashCode() { + return cache.hashCode(); + } + + @Override + public boolean equals(Object obj) { + return obj instanceof FillableCache && + cache.equals(((FillableCache) obj).cache); + } + + @Override + Stream inventories() { + return Stream.concat( + cache.region() + .tileEntities(world) + .filter(FillListener::isFillable) + .filter(block -> !cache.filter().denies(block)) + .map(block -> ((InventoryHolder) block).getInventory()), + cache.region() + .entities(world) + .filter(FillListener::isFillable) + .filter(entity -> !cache.filter().denies(new EntityQuery(entity))) + .map(entity -> ((InventoryHolder) entity).getInventory()) + ); + } + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/loot/Filler.java b/PGM/src/main/java/tc/oc/pgm/loot/Filler.java new file mode 100644 index 0000000..0c4f083 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/loot/Filler.java @@ -0,0 +1,45 @@ +package tc.oc.pgm.loot; + +import java.util.Optional; + +import java.time.Duration; +import tc.oc.commons.core.util.TimeUtils; +import tc.oc.pgm.features.FeatureDefinition; +import tc.oc.pgm.features.FeatureInfo; +import tc.oc.pgm.filters.Filter; +import tc.oc.pgm.filters.matcher.StaticFilter; + +@FeatureInfo(name = "fill", plural = "lootables", singular = "fill") +public interface Filler extends FeatureDefinition { + + /** + * Items to fill with + */ + @Property Loot loot(); + + /** + * Blocks/entities that are fillable + */ + @Property default Filter filter() { + return StaticFilter.ALLOW; + } + + /** + * Refill all blocks/entities when this filter goes high + */ + @Property Optional refill_trigger(); + + /** + * Refill an individual block/entity this much time after it was last filled + */ + @Property default Duration refill_interval() { + return TimeUtils.INF_POSITIVE; + } + + /** + * Clear contents before refilling + */ + @Property default boolean refill_clear() { + return true; + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/loot/Loot.java b/PGM/src/main/java/tc/oc/pgm/loot/Loot.java new file mode 100644 index 0000000..0104461 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/loot/Loot.java @@ -0,0 +1,13 @@ +package tc.oc.pgm.loot; + +import org.bukkit.inventory.ItemStack; +import tc.oc.pgm.compose.Composition; +import tc.oc.pgm.features.FeatureDefinition; +import tc.oc.pgm.features.FeatureInfo; +import tc.oc.pgm.xml.finder.Parent; + +@FeatureInfo(name = "loot", plural = "lootables", singular = "loot") +public interface Loot extends FeatureDefinition { + @Nodes(Parent.class) + @Property Composition items(); +} diff --git a/PGM/src/main/java/tc/oc/pgm/loot/LootManifest.java b/PGM/src/main/java/tc/oc/pgm/loot/LootManifest.java new file mode 100644 index 0000000..da3f9f7 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/loot/LootManifest.java @@ -0,0 +1,33 @@ +package tc.oc.pgm.loot; + +import org.bukkit.inventory.ItemStack; +import tc.oc.commons.core.inject.HybridManifest; +import tc.oc.pgm.compose.ComposableManifest; +import tc.oc.pgm.features.FeatureBinder; +import tc.oc.pgm.match.MatchScope; +import tc.oc.pgm.match.inject.MatchBinders; +import tc.oc.pgm.match.inject.MatchScoped; +import tc.oc.pgm.xml.parser.ParserBinders; + +public class LootManifest extends HybridManifest implements MatchBinders, ParserBinders { + + @Override + protected void configure() { + final FeatureBinder loot = new FeatureBinder<>(binder(), Loot.class); + loot.installReflectiveParser(); + loot.installRootParser(); + + final FeatureBinder fill = new FeatureBinder<>(binder(), Filler.class); + fill.installReflectiveParser(); + fill.installRootParser(); + + final FeatureBinder cache = new FeatureBinder<>(binder(), Cache.class); + cache.installReflectiveParser(); + cache.installRootParser(); + + install(new ComposableManifest(){}); // Parser> + + bind(FillListener.class).in(MatchScoped.class); + matchListener(FillListener.class, MatchScope.LOADED); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/map/Contributor.java b/PGM/src/main/java/tc/oc/pgm/map/Contributor.java new file mode 100644 index 0000000..51509cf --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/map/Contributor.java @@ -0,0 +1,105 @@ +package tc.oc.pgm.map; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import javax.annotation.Nullable; + +import net.md_5.bungee.api.chat.BaseComponent; +import tc.oc.api.docs.virtual.UserDoc; +import tc.oc.commons.bukkit.chat.NameStyle; +import tc.oc.commons.bukkit.chat.Named; +import tc.oc.commons.bukkit.chat.PlayerComponent; +import tc.oc.commons.bukkit.nick.Identity; +import tc.oc.commons.bukkit.nick.IdentityProvider; +import tc.oc.commons.core.chat.Component; +import tc.oc.pgm.PGM; + +import static com.google.common.base.Preconditions.checkArgument; + +/** + * A contributor to a {@link PGMMap}. Can have either or both of a UUID + * and arbitrary String name. If a UUID is present, it is used to lookup + * a username when the map loads. The fallback name is only used if the + * lookup fails, or no UUID is provided (this could be used to credit + * somebody without a Minecraft account, like mom or Jesus). + */ +public class Contributor implements Named { + protected final @Nullable UUID uuid; + protected final @Nullable String fallbackName; + protected final @Nullable String contribution; + + protected @Nullable UserDoc.Identity user; + + /** Creates a contributor with a name and a contribution. */ + public Contributor(@Nullable UUID uuid, @Nullable String fallbackName, @Nullable String contribution) { + this.uuid = uuid; + this.fallbackName = fallbackName; + this.contribution = contribution; + + checkArgument(uuid != null || fallbackName != null); + } + + @Override + public String toString() { + return this.getName(); + } + + public @Nullable UUID getUuid() { + return uuid; + } + + /** Gets the name of this contributor. */ + public @Nullable String getName() { + return user != null ? user.username() : this.fallbackName; + } + + public @Nullable UserDoc.Identity getUser() { + return user; + } + + public void setUser(UserDoc.Identity user) { + this.user = user; + } + + public @Nullable Identity getIdentity() { + return user == null ? null : PGM.get().injector().getInstance(IdentityProvider.class).createIdentity(getUser(), null); + } + + @Override + public BaseComponent getStyledName(NameStyle style) { + return user != null ? new PlayerComponent(getIdentity(), style) + : new Component(fallbackName); + } + + /** + * @return true only if a username is available + */ + public boolean hasName() { + return this.user != null || this.fallbackName != null; + } + + public boolean needsLookup() { + return this.uuid != null && this.user == null; + } + + /** Indicates whether or not this contributor has a specific contribution. */ + public boolean hasContribution() { + return this.contribution != null; + } + + /** Gets this contributor's contribution or null if none exists. */ + public @Nullable String getContribution() { + return this.contribution; + } + + public static List filterNamed(List contributors) { + List resolved = new ArrayList<>(); + for(Contributor contributor : contributors) { + if(contributor.hasName()) { + resolved.add(contributor); + } + } + return resolved; + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/map/MapConfiguration.java b/PGM/src/main/java/tc/oc/pgm/map/MapConfiguration.java new file mode 100644 index 0000000..a7d91f1 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/map/MapConfiguration.java @@ -0,0 +1,14 @@ +package tc.oc.pgm.map; + +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +public interface MapConfiguration { + List includePaths(); + List globalIncludes(); + Map environment(); + boolean autoReload(); + boolean reloadWhenError(); + List sources(); +} diff --git a/PGM/src/main/java/tc/oc/pgm/map/MapDefinition.java b/PGM/src/main/java/tc/oc/pgm/map/MapDefinition.java new file mode 100644 index 0000000..9768368 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/map/MapDefinition.java @@ -0,0 +1,137 @@ +package tc.oc.pgm.map; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import javax.annotation.Nullable; +import javax.inject.Inject; +import javax.inject.Provider; + +import com.google.common.base.Joiner; +import com.google.common.collect.ImmutableList; +import com.google.common.hash.HashCode; +import com.google.common.hash.Hashing; +import com.google.common.io.Files; +import com.google.inject.Injector; +import tc.oc.commons.core.exception.ExceptionHandler; +import tc.oc.pgm.map.inject.MapInjectionScope; +import tc.oc.pgm.module.ModuleLoadException; +import tc.oc.pgm.xml.UnsupportedMapProtocolException; + +import static tc.oc.commons.core.inject.Injection.unwrappingExceptions; + +/** + * Base class for an XML configured map. + * + * This class is responsible for parsing an XML file into a + * {@link MapModuleContext}, detecting changes that require a reload, + * and creation of a child {@link Injector} with bindings specific to + * the map. + * + * This class is relatively decoupled from the rest of PGM, so that it can + * potentially be used to integrate PGM features into other applications, + * such as the lobby, or a non-match based game, e.g. an MMO. + * + * Anything related specifically to matches should be in {@link PGMMap}. + */ +public class MapDefinition { + + @Inject private MapConfiguration configuration; + @Inject private Provider contextProvider; + @Inject private ExceptionHandler exceptionHandler; + @Inject private MapInjectionScope mapInjectionScope; + + private MapLogger logger; + @Inject void init(MapLogger.Factory loggerFactory) { + this.logger = loggerFactory.create(this); + } + + private final MapFolder folder; + + protected @Nullable MapModuleContext context; + + protected MapDefinition(MapFolder folder) { + this.folder = folder; + } + + + public MapLogger getLogger() { + return logger; + } + + public MapFolder getFolder() { + return folder; + } + + public String getDottedPath() { + return Joiner.on(".").join(getFolder().getRelativePath()); + } + + public String getName() { + return getFolder().getRelativePath().toString(); + } + + public boolean isLoaded() { + return context != null; + } + + public MapModuleContext getContext() { + if(context == null) { + throw new IllegalStateException("Map is not loaded: " + this); + } + return context; + } + + public boolean shouldReload() { + if(context == null) return true; + if(!configuration.autoReload()) return false; + if(context.loadedFiles().isEmpty()) return configuration.reloadWhenError(); + + try { + for(Map.Entry loaded : context.loadedFiles().entrySet()) { + HashCode latest = Files.hash(loaded.getKey().toFile(), Hashing.sha256()); + if(!latest.equals(loaded.getValue())) return true; + } + + return false; + } catch (IOException e) { + return true; + } + } + + public boolean reload() throws MapNotFoundException { + List errors; + + try { + final MapModuleContext newContext = mapInjectionScope.withNewStore(this, () -> { + final MapModuleContext context = unwrappingExceptions(ModuleLoadException.class, contextProvider); + context.load(); + return context; + }); + + if(!newContext.hasErrors()) { + this.context = newContext; + return true; + } + + errors = newContext.getErrors(); + } catch(MapNotFoundException e) { + throw e; + } catch(UnsupportedMapProtocolException e) { + logger.warning("Skipping map with unsupported proto " + e.getProto()); + errors = ImmutableList.of(); + } catch(ModuleLoadException e) { + errors = ImmutableList.of(e); + } catch(Throwable e) { + exceptionHandler.handleException(e); + errors = ImmutableList.of(new ModuleLoadException("Internal error", e)); + } + + for(ModuleLoadException error : errors) { + logger.log(new MapLogRecord(this, error)); + } + + return false; + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/map/MapDocument.java b/PGM/src/main/java/tc/oc/pgm/map/MapDocument.java new file mode 100644 index 0000000..9fb0e43 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/map/MapDocument.java @@ -0,0 +1,130 @@ +package tc.oc.pgm.map; + +import java.net.URL; +import java.nio.file.Path; +import java.util.Collection; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import javax.annotation.Nullable; +import javax.inject.Inject; + +import tc.oc.api.docs.AbstractModel; +import tc.oc.api.docs.SemanticVersion; +import tc.oc.api.docs.virtual.MapDoc; +import tc.oc.pgm.map.inject.MapScoped; +import tc.oc.pgm.modules.InfoModule; +import tc.oc.pgm.teams.TeamFactory; + +import static tc.oc.commons.core.stream.Collectors.toImmutableList; + +@MapScoped +public class MapDocument extends AbstractModel implements MapDoc { + + private final MapFolder folder; + private final InfoModule infoModule; + private final MapInfo info; + private final List teams; + + @Inject private MapDocument(MapFolder folder, InfoModule infoModule, MapInfo info, List teams) { + this.folder = folder; + this.infoModule = infoModule; + this.info = info; + this.teams = teams.stream() + .map(TeamFactory::getDocument) + .collect(toImmutableList()); + } + + @Override + public String _id() { + return info.id.toString(); + } + + @Override + public String slug() { + return info.slug(); + } + + @Override + public MapDoc.Edition edition() { + return info.edition(); + } + + @Override + public Phase phase() { + return info.phase(); + } + + @Override + public String name() { + return info.name; + } + + @Override + public Set gamemode() { // (s) + return infoModule.getGamemodes(); + } + + @Override + public String objective() { + return info.objective.toPlainText(); + } + + @Override + public int min_players() { + return infoModule.getGlobalPlayerLimits().lowerEndpoint(); + } + + @Override + public int max_players() { + return infoModule.getGlobalPlayerLimits().upperEndpoint(); + } + + @Override + public Path path() { + return folder.getAbsolutePath(); + } + + @Override + public @Nullable String url() { + final URL url = folder.getUrl(); + return url == null ? null : url.toExternalForm(); + } + + @Override + public Collection images() { + return folder.getThumbnails(); + } + + @Override + public SemanticVersion version() { + return info.version; + } + + @Override + public MapDoc.Genre genre() { + return info.genre; + } + + @Override + public List teams() { + return teams; + } + + @Override + public Collection author_uuids() { + return info.authors.stream() + .map(Contributor::getUuid) + .filter(uuid -> uuid != null) + .collect(toImmutableList()); + } + + @Override + public Collection contributor_uuids() { + return info.contributors.stream() + .map(Contributor::getUuid) + .filter(uuid -> uuid != null) + .collect(toImmutableList()); + } + +} diff --git a/PGM/src/main/java/tc/oc/pgm/map/MapFilePreprocessor.java b/PGM/src/main/java/tc/oc/pgm/map/MapFilePreprocessor.java new file mode 100644 index 0000000..d2e8c00 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/map/MapFilePreprocessor.java @@ -0,0 +1,202 @@ +package tc.oc.pgm.map; + +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Stack; +import java.util.logging.Logger; +import javax.annotation.Nullable; +import javax.inject.Inject; + +import com.google.common.base.Joiner; +import com.google.common.collect.Iterables; +import com.google.common.hash.HashCode; +import com.google.common.hash.Hashing; +import com.google.inject.assistedinject.Assisted; +import org.jdom2.Content; +import org.jdom2.Document; +import org.jdom2.Element; +import org.jdom2.JDOMException; +import org.jdom2.input.JDOMParseException; +import org.jdom2.input.SAXBuilder; +import tc.oc.commons.core.logging.Loggers; +import tc.oc.commons.core.util.HashingInputStream; +import tc.oc.pgm.utils.XMLUtils; +import tc.oc.pgm.xml.InvalidXMLException; +import tc.oc.pgm.xml.Node; + +import static com.google.common.base.Preconditions.checkNotNull; + +public class MapFilePreprocessor { + + public interface Factory { + MapFilePreprocessor create(MapSource source); + } + + private final SAXBuilder builder; + private final Logger logger; + private final MapConfiguration mapConfiguration; + private final MapSource source; + private final Stack includeStack = new Stack<>(); + private final Map includedFiles = new HashMap<>(); + + @Inject MapFilePreprocessor(@Assisted MapSource source, Loggers loggers, SAXBuilder builder, MapConfiguration mapConfiguration) { + this.source = source; + this.logger = loggers.get(getClass(), source.getPath().toString()); + this.builder = builder; + this.mapConfiguration = mapConfiguration; + } + + public Map getIncludedFiles() { + return includedFiles; + } + + public Document readRootDocument(Path file) throws InvalidXMLException { + checkNotNull(file, "file"); + + this.includeStack.clear(); + + Document result = this.readDocument(file); + + this.includeStack.clear(); + + if(source.globalIncludes()) { + for(Path globalInclude : mapConfiguration.globalIncludes()) { + final Path includePath = findIncludeFile(null, globalInclude, null); + if(includePath != null) { + result.getRootElement().addContent(0, readIncludedDocument(includePath, null)); + } + } + } + + return result; + } + + private Document readDocument(Path absolutePath) throws InvalidXMLException { + final Path relativePath = source.getPath().relativize(absolutePath); + Document doc; + + try(HashingInputStream istream = new HashingInputStream(Hashing.sha256(), new FileInputStream(absolutePath.toFile()))) { + doc = this.builder.build(istream); + doc.setBaseURI(relativePath.toString()); + this.includedFiles.put(absolutePath, istream.hash()); + + } catch(FileNotFoundException e) { + throw new InvalidXMLException("File not found", absolutePath.toString()); + } catch(IOException e) { + throw new InvalidXMLException("Error reading file: " + e.getMessage(), absolutePath.toString()); + } catch(JDOMParseException e) { + throw InvalidXMLException.fromJDOM(e, absolutePath.toString()); + } catch(JDOMException e) { + throw new InvalidXMLException("Unhandled " + e.getClass().getSimpleName(), absolutePath.toString(), e); + } + + this.processChildren(absolutePath, doc.getRootElement()); + + return doc; + } + + private @Nullable Path findIncludeFile(@Nullable Path basePath, Path includeFile, @Nullable Element includeElement) throws InvalidXMLException { + Iterable includePaths = mapConfiguration.includePaths(); + if(basePath != null) { + includePaths = Iterables.concat(includePaths, Collections.singleton(basePath)); + } + + for(Path includePath : includePaths) { + Path fullPath = includePath.resolve(includeFile); + if(Files.isRegularFile(fullPath)) { + return fullPath.toAbsolutePath(); + } + } + + return null; + } + + private List readIncludedDocument(@Nullable Path basePath, Path includeFile, @Nullable Element includeElement) throws InvalidXMLException { + final Path fullPath = findIncludeFile(basePath, includeFile, includeElement); + if(fullPath == null) { + throw new InvalidXMLException("Failed to find include: " + includeFile, includeElement); + } + return readIncludedDocument(fullPath, includeElement); + } + + private List readIncludedDocument(Path fullPath, @Nullable Element includeElement) throws InvalidXMLException { + if(includeStack.contains(fullPath)) { + throw new InvalidXMLException("Circular include: " + Joiner.on(" --> ").join(includeStack), includeElement); + } + + includeStack.push(fullPath); + try { + return readDocument(fullPath).getRootElement().cloneContent(); + } finally { + includeStack.pop(); + } + } + + private List processIncludeElement(Path baseFile, Element el) throws InvalidXMLException { + Path path = XMLUtils.parseRelativePath(Node.fromRequiredAttr(el, "src")); + return readIncludedDocument(baseFile.getParent(), path, el); + } + + private T getEnvironment(String key, Class type, Node node) throws InvalidXMLException { + Object value = mapConfiguration.environment().get(key); + if(value == null) { + logger.warning("Unknown environment variable '" + key + "', using default value: false"); + value = false; + } + if(!type.isInstance(value)) { + throw new InvalidXMLException("Wrong variable type, expected " + type.getSimpleName() + ", was " + value.getClass().getSimpleName(), node); + } + return type.cast(value); + } + + private List processConditional(Element el, boolean invert) throws InvalidXMLException { + for(Node attr : Node.fromAttrs(el)) { + boolean expected = XMLUtils.parseBoolean(attr); + boolean actual = getEnvironment(attr.getName(), Boolean.class, attr); + if(expected != actual) { + return invert ? el.cloneContent() : Collections.emptyList(); + } + } + + return invert ? Collections.emptyList() : el.cloneContent(); + } + + private void processChildren(Path file, Element parent) throws InvalidXMLException { + for(int i = 0; i < parent.getContentSize(); i++) { + Content content = parent.getContent(i); + if(!(content instanceof Element)) continue; + + Element child = (Element) content; + List replacement = null; + + switch(child.getName()) { + case "include": + replacement = processIncludeElement(file, child); + break; + + case "if": + replacement = processConditional(child, false); + break; + + case "unless": + replacement = processConditional(child, true); + break; + } + + if(replacement != null) { + parent.removeContent(i); + parent.addContent(i, replacement); + i--; // Process replacement content + } else { + processChildren(file, child); + } + } + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/map/MapFolder.java b/PGM/src/main/java/tc/oc/pgm/map/MapFolder.java new file mode 100644 index 0000000..724590f --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/map/MapFolder.java @@ -0,0 +1,124 @@ +package tc.oc.pgm.map; + +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collection; +import java.util.Collections; +import java.util.Objects; +import javax.annotation.Nullable; + +import tc.oc.commons.core.util.Utils; + +public class MapFolder { + + public static final String MAP_DESCRIPTION_FILE_NAME = "map.xml"; + public static final String THUMBNAIL_FILE_NAME = "map.png"; + + public static boolean isMapFolder(Path path) { + return Files.isDirectory(path) && Files.isRegularFile(path.resolve(MAP_DESCRIPTION_FILE_NAME)); + } + + private final MapSource source; + private final Path path; + private Collection thumbnails; + + public MapFolder(MapSource source, Path path) { + this.source = source; + this.path = path; + } + + @Override + public String toString() { + return getClass().getSimpleName() + + "{source=" + source + + " path=" + getRelativePath() + + "}"; + } + + @Override + public int hashCode() { + return Objects.hash(source, getRelativePath()); + } + + @Override + public boolean equals(Object obj) { + return Utils.equals(MapFolder.class, this, obj, that -> + this.getSource().equals(that.getSource()) && + this.getRelativePath().equals(that.getRelativePath()) + ); + } + + public MapSource getSource() { + return source; + } + + public Path getAbsolutePath() { + return source.getPath().resolve(path); + } + + public Path getRelativePath() { + return source.getPath().relativize(path); + } + + public Path getAbsoluteDescriptionFilePath() { + return getAbsolutePath().resolve(MAP_DESCRIPTION_FILE_NAME); + } + + public Path getRelativeDescriptionFilePath() { + return getRelativePath().resolve(MAP_DESCRIPTION_FILE_NAME); + } + + private @Nullable URL getRelativeUrl(Path path) { + // Resolving a Path against a URL is surprisingly tricky, due to character escaping issues. + // The safest approach seems to be appending the path components one at a time, wrapping + // each one in a URI to ensure that the filename is properly escaped. Trying to append the + // entire thing at once either fails to escape illegal chars at all, or escapes characters + // that shouldn't be, like the path seperator. + try { + URL url = source.getUrl(); + if(url == null) return null; + + URI uri = url.toURI(); + + if(uri.getPath() == null || "".equals(uri.getPath())) { + uri = uri.resolve("/"); + } + + Path dir = Files.isDirectory(source.getPath().resolve(path)) ? path : path.getParent(); + if(dir == null) return null; + for(Path part : dir) { + uri = uri.resolve(new URI(null, null, part.toString() + "/", null)); + } + if(path != dir) { + uri = uri.resolve(new URI(null, null, path.getFileName().toString(), null)); + } + + return uri.toURL(); + } catch(MalformedURLException | URISyntaxException e) { + return null; + } + } + + public @Nullable URL getUrl() { + return getRelativeUrl(getRelativePath()); + } + + public @Nullable URL getDescriptionFileUrl() { + return getRelativeUrl(getRelativeDescriptionFilePath()); + } + + public Collection getThumbnails() { + if(thumbnails == null) { + if(Files.isRegularFile(getAbsolutePath().resolve(THUMBNAIL_FILE_NAME))) { + thumbnails = Collections.singleton(THUMBNAIL_FILE_NAME); + } else { + thumbnails = Collections.emptySet(); + } + } + return thumbnails; + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/map/MapId.java b/PGM/src/main/java/tc/oc/pgm/map/MapId.java new file mode 100644 index 0000000..21a408b --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/map/MapId.java @@ -0,0 +1,96 @@ +package tc.oc.pgm.map; + +import java.util.Objects; + +import tc.oc.api.docs.virtual.MapDoc; +import tc.oc.commons.core.formatting.StringUtils; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +public class MapId { + public static final String SLUG_PATTERN = "^[a-z0-9_]+$"; + + private final String slug; + private final MapDoc.Edition edition; + private final MapDoc.Phase phase; + + public MapId(String slug, MapDoc.Edition edition, MapDoc.Phase phase) { + this.slug = checkNotNull(slug); + this.edition = checkNotNull(edition); + this.phase = checkNotNull(phase); + + checkArgument(slug.matches(SLUG_PATTERN), "Invalid map slug \"" + slug + '"'); + } + + /** + * Note: it's important that the input is not changed if it is already a valid slug + */ + public static String slugifyName(String name) { + return StringUtils.slugify(name, '_'); + } + + /** + * Parse a {@link MapId} from the given string. + * + * If the input is a valid "slug:edition:phase" format, it is parsed as such, + * otherwise the entire input is treated as a map name and slugified to create + * a MapId with the default edition and phase. + */ + public static MapId parse(String text) { + String[] parts = text.split(":"); + + if(parts.length == 3 && parts[0].equals(slugifyName(parts[0]))) { + try { + final MapDoc.Edition edition = MapDoc.Edition.valueOf(parts[1].toUpperCase()); + final MapDoc.Phase phase = MapDoc.Phase.valueOf(parts[2].toUpperCase()); + return new MapId(parts[0], edition, phase); + } catch(IllegalArgumentException ignored) {} + } + + return new MapId(slugifyName(text), + MapDoc.Edition.STANDARD, + MapDoc.Phase.PRODUCTION); + } + + public String slug() { + return slug; + } + + public MapDoc.Edition edition() { + return edition; + } + + public MapDoc.Phase phase() { + return phase; + } + + public boolean isDefault() { + return edition == MapDoc.Edition.DEFAULT && + phase == MapDoc.Phase.DEFAULT; + } + + @Override + public String toString() { + if(isDefault()) { + return slug; + } else { + return slug + ':' + edition.name().toLowerCase() + ':' + phase.name().toLowerCase(); + } + } + + @Override + public boolean equals(Object o) { + if(this == o) return true; + if(!(o instanceof MapId)) return false; + MapId id = (MapId) o; + return phase == id.phase && + Objects.equals(slug, id.slug) && + edition == id.edition; + } + + @Override + public int hashCode() { + return Objects.hash(slug, edition, phase); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/map/MapInfo.java b/PGM/src/main/java/tc/oc/pgm/map/MapInfo.java new file mode 100644 index 0000000..b421640 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/map/MapInfo.java @@ -0,0 +1,192 @@ +package tc.oc.pgm.map; + +import java.util.List; +import java.util.Set; +import javax.annotation.Nullable; + +import com.google.common.collect.ComparisonChain; +import com.google.common.collect.Iterables; +import net.md_5.bungee.api.chat.BaseComponent; +import net.md_5.bungee.api.chat.TranslatableComponent; +import org.bukkit.Difficulty; +import org.bukkit.World.Environment; +import org.bukkit.command.CommandSender; +import tc.oc.api.docs.PlayerId; +import tc.oc.api.docs.SemanticVersion; +import tc.oc.api.docs.virtual.MapDoc; +import tc.oc.commons.bukkit.localization.Translations; +import tc.oc.commons.core.chat.Component; +import tc.oc.commons.core.chat.Components; +import tc.oc.commons.core.formatting.StringUtils; + +import static com.google.common.base.Preconditions.checkNotNull; +import static net.md_5.bungee.api.ChatColor.*; + +/** Class describing the match-independent information about a map. */ +public class MapInfo implements Comparable { + + public final MapId id; + + public final SemanticVersion proto; + + /** Name of the map. */ + public final String name; + + public final SemanticVersion version; + + /** Optional game name to override the default */ + public final @Nullable BaseComponent game; + + public final MapDoc.Genre genre; + + public final Set gamemodes; + + /** Short, one-line description of the objective of this map. */ + public final BaseComponent objective; + + /** List of authors and their contributions. */ + public final List authors; + + /** List of contributors and their contributions. */ + public final List contributors; + + /** List of rules for this map. */ + public final List rules; + + /** Difficulty the map should be played on. */ + public final @Nullable Difficulty difficulty; + + /** Dimension the map should be loaded in */ + public final Environment dimension; + + /** Whether friendly fire should be on or off. */ + public final boolean friendlyFire; + + public MapInfo(SemanticVersion proto, + @Nullable String slug, + String name, + SemanticVersion version, + MapDoc.Edition edition, + MapDoc.Phase phase, + @Nullable BaseComponent game, + MapDoc.Genre genre, + Set gamemodes, + BaseComponent objective, + List authors, + List contributors, + List rules, + @Nullable Difficulty difficulty, + Environment dimension, + boolean friendlyFire) { + + this.id = new MapId(slug != null ? slug : MapId.slugifyName(name), edition, phase); + + this.proto = checkNotNull(proto); + this.name = checkNotNull(name); + this.version = checkNotNull(version); + this.game = game; + this.genre = checkNotNull(genre); + this.gamemodes = checkNotNull(gamemodes); + this.objective = checkNotNull(objective); + this.authors = checkNotNull(authors); + this.contributors = checkNotNull(contributors); + this.rules = checkNotNull(rules); + this.difficulty = difficulty; + this.dimension = checkNotNull(dimension); + this.friendlyFire = friendlyFire; + + } + + public String slug() { return id.slug(); } + public MapDoc.Edition edition() { return id.edition(); } + public MapDoc.Phase phase() { return id.phase(); } + + public String getFormattedMapTitle() { + return StringUtils.dashedChatMessage(DARK_AQUA + " " + this.name + GRAY + " " + version, "-", RED + "" + STRIKETHROUGH); + } + + public String getShortDescription(CommandSender sender) { + String text = GOLD + this.name; + + List authors = getNamedAuthors(); + if(!authors.isEmpty()) { + text = Translations.get().t( + DARK_PURPLE.toString(), + "misc.authorship", + sender, + text, + Translations.get().legacyList( + sender, + DARK_PURPLE.toString(), + RED.toString(), + authors + ) + ); + } + + return text; + } + + /** + * Apply standard formatting (aqua + bold) to the map name + */ + public String getColoredName() { + return AQUA.toString() + BOLD + this.name; + } + + public BaseComponent getComponentName() { + return new Component(name, AQUA, BOLD); + } + + /** + * Apply standard formatting (aqua + bold) to the map version + */ + public String getColoredVersion() { + return DARK_AQUA.toString() + BOLD + version; + } + + public List getNamedAuthors() { + return Contributor.filterNamed(this.authors); + } + + public List getNamedContributors() { + return Contributor.filterNamed(this.contributors); + } + + public Iterable getAllContributors() { + return Iterables.concat(authors, contributors); + } + + public boolean isAuthor(PlayerId player) { + for(Contributor author : authors) { + if(player.equals(author.getUser())) return true; + } + return false; + } + + public BaseComponent getLocalizedGenre() { + switch(genre) { + case OBJECTIVES: return new TranslatableComponent("map.genre.objectives"); + case DEATHMATCH: return new TranslatableComponent("map.genre.deathmatch"); + default: return new TranslatableComponent("map.genre.other"); + } + } + + public BaseComponent getLocalizedEdition() { + switch(edition()) { + case STANDARD: return new TranslatableComponent("map.edition.standard"); + case RANKED: return new TranslatableComponent("map.edition.ranked"); + case TOURNAMENT: return new TranslatableComponent("map.edition.tournament"); + default: return Components.blank(); + } + } + + @Override + public int compareTo(MapInfo o) { + return ComparisonChain.start() + .compare(name, o.name) + .compare(edition(), o.edition()) + .compare(phase(), o.phase()) + .result(); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/map/MapLibrary.java b/PGM/src/main/java/tc/oc/pgm/map/MapLibrary.java new file mode 100644 index 0000000..8aba744 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/map/MapLibrary.java @@ -0,0 +1,50 @@ +package tc.oc.pgm.map; + +import java.nio.file.Path; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.logging.Logger; +import javax.annotation.Nullable; + +import com.google.common.util.concurrent.ListenableFuture; +import tc.oc.api.maps.MapUpdateMultiResponse; + +public interface MapLibrary { + + Set addMaps(Collection maps); + + boolean addMap(PGMMap map); + + void removeMaps(Collection paths); + + boolean removeMap(Path path); + + Logger getLogger(); + + Collection getMaps(); + + Set getMapNames(); + + Map getMapsByPath(); + + @Nullable PGMMap getMapById(String mapId); + + @Nullable PGMMap getMapById(MapId mapId); + + PGMMap needMapById(String mapId); + + PGMMap needMapById(MapId mapId); + + Optional getMapByNameOrId(String nameOrId); + + List resolveMaps(List namesOrIds); + + Collection getDirtyMaps(); + + ListenableFuture pushAllMaps(); + + ListenableFuture pushDirtyMaps(); +} diff --git a/PGM/src/main/java/tc/oc/pgm/map/MapLibraryImpl.java b/PGM/src/main/java/tc/oc/pgm/map/MapLibraryImpl.java new file mode 100644 index 0000000..3918e86 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/map/MapLibraryImpl.java @@ -0,0 +1,245 @@ +package tc.oc.pgm.map; + +import java.nio.file.Path; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.logging.Logger; +import javax.annotation.Nullable; +import javax.inject.Inject; +import javax.inject.Singleton; + +import com.google.common.collect.Collections2; +import com.google.common.collect.HashMultimap; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Lists; +import com.google.common.collect.Maps; +import com.google.common.collect.SetMultimap; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.SettableFuture; +import tc.oc.api.docs.virtual.UserDoc; +import tc.oc.api.maps.MapService; +import tc.oc.api.maps.MapUpdateMultiResponse; +import tc.oc.minecraft.scheduler.SyncExecutor; +import tc.oc.commons.core.logging.Loggers; +import tc.oc.commons.core.util.Pair; +import tc.oc.commons.core.util.SystemFutureCallback; + +@Singleton +public class MapLibraryImpl implements MapLibrary { + + private final SyncExecutor syncExecutor; + private final MapService mapService; + + protected final Map mapsById = Maps.newHashMap(); + protected final Map mapsByPath = new HashMap<>(); + protected final SetMultimap mapsByName = HashMultimap.create(); + protected final Logger logger; + + @Inject MapLibraryImpl(Loggers loggers, SyncExecutor syncExecutor, MapService mapService) { + this.syncExecutor = syncExecutor; + this.mapService = mapService; + this.logger = loggers.get(getClass()); + } + + @Override + public Set addMaps(Collection maps) { + Set added = new HashSet<>(); + for(PGMMap map : maps) { + if(addMap(map)) added.add(map); + } + return added; + } + + @Override + public boolean addMap(PGMMap map) { + final MapId id = map.getId(); + PGMMap old = mapsById.get(id); + + if(old == null) { + logger.fine("Adding " + id); + } else if(old.getSource().hasPriorityOver(map.getSource())) { + logger.fine("Skipping duplicate " + id); + return false; + } else { + logger.fine("Replacing duplicate " + id); + } + + mapsById.put(id, map); + mapsByPath.put(map.getFolder().getAbsolutePath(), map); + mapsByName.put(map.getName(), map); + return true; + } + + @Override + public void removeMaps(Collection paths) { + for(Path path : paths) removeMap(path); + } + + @Override + public boolean removeMap(Path path) { + PGMMap map = mapsByPath.remove(path); + if(map == null) return false; + + mapsById.remove(map.getId()); + mapsByName.remove(map.getName(), map); + return true; + } + + @Override + public Logger getLogger() { + return this.logger; + } + + @Override + public Collection getMaps() { + return this.mapsById.values(); + } + + @Override + public Set getMapNames() { + return mapsByName.keySet(); + } + + @Override + public Map getMapsByPath() { + return mapsByPath; + } + + @Override + public @Nullable PGMMap getMapById(String mapId) { + return getMapById(MapId.parse(mapId)); + } + + @Override + public @Nullable PGMMap getMapById(MapId mapId) { + return mapsById.get(mapId); + } + + @Override + public PGMMap needMapById(String mapId) { + return needMapById(MapId.parse(mapId)); + } + + @Override + public PGMMap needMapById(MapId mapId) { + final PGMMap map = getMapById(mapId); + if(map == null) { + throw new IllegalStateException("No map with ID '" + mapId + "'"); + } + return map; + } + + @Override + public Optional getMapByNameOrId(String nameOrId) { + Set maps = mapsByName.get(nameOrId); + + if(maps.isEmpty()) { + return Optional.ofNullable(mapsById.get(MapId.parse(nameOrId))); + } + + PGMMap best = null; + for(PGMMap map : maps) { + if(best == null || map.getSource().hasPriorityOver(best.getSource())) { + best = map; + } + } + + return Optional.of(best); + } + + @Override + public List resolveMaps(List namesOrIds) { + List mapResult = Lists.newArrayList(); + for(String slug : namesOrIds) { + Optional map = this.getMapByNameOrId(slug); + if(map.isPresent()) { + mapResult.add(map.get()); + } else { + this.logger.warning("Could not find map: " + slug); + } + } + return mapResult; + } + + @Override + public Collection getDirtyMaps() { + return Collections2.filter(getMaps(), (map) -> !map.isPushed()); + } + + @Override + public ListenableFuture pushAllMaps() { + return pushMaps(getMaps()); + } + + @Override + public ListenableFuture pushDirtyMaps() { + return pushMaps(getDirtyMaps()); + } + + private ListenableFuture pushMaps(final Collection maps) { + final Set pushedMaps = ImmutableSet.copyOf(maps); + logger.info("Pushing " + pushedMaps.size() + " maps"); + + final SettableFuture future = SettableFuture.create(); + syncExecutor.callback( + mapService.updateMapsAndLookupAuthors(Collections2.transform(pushedMaps, PGMMap::getDocument)), + new SystemFutureCallback() { + @Override public void onSuccessThrows(MapUpdateMultiResponse result) throws Exception { + logger.info("Push complete: " + result); + logErrors(result); + pushedMaps.forEach(PGMMap::markPushed); + resolveContributors(pushedMaps, result.users_by_uuid); + future.set(result); + } + + @Override + public void onFailure(Throwable e) { + super.onFailure(e); + future.setException(e); + } + } + ); + + return future; + } + + private void resolveContributors(Collection maps, Map usersByUuid) { + Set> missing = new HashSet<>(); + + for(PGMMap map : maps) { + for(Contributor contributor : map.getInfo().getAllContributors()) { + if(contributor.needsLookup()) { + final UserDoc.Identity user = usersByUuid.get(contributor.getUuid()); + if(user != null) { + contributor.setUser(user); + } else { + missing.add(Pair.create(map, contributor)); + } + } + } + } + + logger.info("Resolved " + usersByUuid.size() + " contributor UUIDs"); + + for(Pair pair : missing) { + pair.first.getLogger().severe("Contributor UUID not found: " + pair.second.getUuid()); + } + } + + private void logErrors(MapUpdateMultiResponse response) { + for(Map.Entry>> mapEntry : response.errors.entrySet()) { + final PGMMap map = needMapById(mapEntry.getKey()); + for(Map.Entry> propEntry : mapEntry.getValue().entrySet()) { + for(String message : propEntry.getValue()) { + map.getLogger().severe("Error saving map to database: " + propEntry.getKey() + " " + message); + } + } + } + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/map/MapLoader.java b/PGM/src/main/java/tc/oc/pgm/map/MapLoader.java new file mode 100644 index 0000000..337592a --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/map/MapLoader.java @@ -0,0 +1,17 @@ +package tc.oc.pgm.map; + +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public interface MapLoader { + + List loadNewMaps(Map loaded, Set added, Set updated, Set removed); + + /** + * Load/reload the given map + * @throws MapNotFoundException if the map was not found at its source + */ + boolean loadMap(PGMMap map) throws MapNotFoundException; +} diff --git a/PGM/src/main/java/tc/oc/pgm/map/MapLoaderImpl.java b/PGM/src/main/java/tc/oc/pgm/map/MapLoaderImpl.java new file mode 100644 index 0000000..2754bb6 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/map/MapLoaderImpl.java @@ -0,0 +1,91 @@ +package tc.oc.pgm.map; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.logging.Level; +import java.util.logging.Logger; +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; + +import tc.oc.commons.core.logging.Loggers; +import tc.oc.pgm.development.MapErrorTracker; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +@Singleton +public class MapLoaderImpl implements MapLoader { + + protected final Logger logger; + protected final Path serverRoot; + protected final MapConfiguration config; + protected final PGMMap.Factory mapFactory; + protected final MapErrorTracker mapErrorTracker; + + @Inject MapLoaderImpl(Loggers loggers, @Named("serverRoot") Path serverRoot, MapConfiguration config, PGMMap.Factory mapFactory, MapErrorTracker mapErrorTracker) { + this.mapErrorTracker = mapErrorTracker; + this.logger = loggers.get(getClass()); + this.serverRoot = serverRoot; + this.config = checkNotNull(config); + this.mapFactory = mapFactory; + } + + @Override + public boolean loadMap(PGMMap map) throws MapNotFoundException { + mapErrorTracker.clearErrors(map); + return map.reload(); + } + + @Override + public List loadNewMaps(Map loaded, Set added, Set updated, Set removed) { + checkArgument(added.isEmpty()); + checkArgument(removed.isEmpty()); + + logger.fine("Loading maps..."); + + Set found = new HashSet<>(); + List maps = new ArrayList<>(); + + for(MapSource source : config.sources()) { + try { + for(Path path : source.getMapFolders(logger)) { + try { + found.add(path); + PGMMap map = loaded.get(path); + if(map == null) { + logger.fine(" ADDED " + path); + added.add(path); + + map = mapFactory.create(new MapFolder(source, path)); + if(loadMap(map)) maps.add(map); + } else if(map.shouldReload()) { + logger.fine(" UPDATED " + path); + updated.add(path); + loadMap(map); + } + } catch(MapNotFoundException e) { + // ignore - will be removed below + } + } + } catch(IOException e) { + logger.log(Level.SEVERE, "Exception loading from map source " + source.getPath(), e); + } + } + + for(Path path : loaded.keySet()) { + if(!found.contains(path)) { + logger.fine(" REMOVED " + path); + removed.add(path); + } + } + + logger.fine("Found " + found.size() + " maps, " + added.size() + " new, " + removed.size() + " removed"); + return maps; + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/map/MapLogRecord.java b/PGM/src/main/java/tc/oc/pgm/map/MapLogRecord.java new file mode 100644 index 0000000..77790f4 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/map/MapLogRecord.java @@ -0,0 +1,79 @@ +package tc.oc.pgm.map; + +import java.util.logging.Level; +import java.util.logging.LogRecord; +import javax.annotation.Nullable; + +import org.bukkit.ChatColor; +import org.jdom2.input.JDOMParseException; +import tc.oc.commons.bukkit.logging.ChatLogRecord; +import tc.oc.pgm.module.ModuleLoadException; +import tc.oc.pgm.xml.InvalidXMLException; +import tc.oc.pgm.xml.Node; + +public class MapLogRecord extends ChatLogRecord { + + private final MapDefinition map; + private final String location; + + protected MapLogRecord(MapDefinition map, Level level, @Nullable String location, @Nullable String message, @Nullable Throwable thrown) { + super(level, message != null ? message : thrown != null ? thrown.getMessage() : null); + this.map = map; + if(location == null) { + if(thrown instanceof InvalidXMLException) { + location = ((InvalidXMLException) thrown).getFullLocation(); + } + + if(location == null) { + location = map.getFolder().getRelativeDescriptionFilePath().toString(); + } + } + + this.location = location; + + if(thrown != null) setThrown(thrown); + setLoggerName(map.getLogger().getName()); + } + + public MapLogRecord(MapDefinition map, Level level, Node node, @Nullable String message) { + this(map, level, node.describeWithDocumentAndLocation(), message, null); + } + + public MapLogRecord(MapDefinition map, ModuleLoadException thrown) { + this(map, Level.SEVERE, null, null, thrown); + } + + protected MapLogRecord(MapDefinition map, LogRecord record) { + this(map, record.getLevel(), null, record.getMessage(), record.getThrown()); + } + + @Override + public String getLegacyFormattedMessage() { + String message = ChatColor.AQUA + getLocation() + ": " + ChatColor.RED + getMessage(); + + Throwable thrown = getThrown(); + if(thrown != null && thrown.getCause() != null && thrown.getCause().getMessage() != null) { + message += ", caused by: " + thrown.getCause().getMessage(); + } + + return message; + } + + @Override + public boolean suppressStackTrace() { + // Don't dump the stack if the root cause is just a parse error + Throwable thrown = getThrown(); + while(thrown instanceof InvalidXMLException) thrown = thrown.getCause(); + return thrown == null || + (thrown instanceof JDOMParseException) || + super.suppressStackTrace(); + } + + public MapDefinition getMap() { + return map; + } + + public String getLocation() { + return location; + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/map/MapLogger.java b/PGM/src/main/java/tc/oc/pgm/map/MapLogger.java new file mode 100644 index 0000000..2c30d4f --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/map/MapLogger.java @@ -0,0 +1,41 @@ +package tc.oc.pgm.map; + +import java.util.logging.Level; +import java.util.logging.LogRecord; +import java.util.logging.Logger; +import javax.inject.Inject; + +import com.google.inject.assistedinject.Assisted; +import tc.oc.commons.bukkit.logging.MapdevLogger; +import tc.oc.pgm.xml.Node; + +public class MapLogger extends Logger { + + public interface Factory { + MapLogger create(MapDefinition map); + } + + private final MapDefinition map; + + @Inject MapLogger(@Assisted MapDefinition map, MapdevLogger mapdevLogger) { + super(mapdevLogger.getName() + "." + map.getDottedPath(), null); + this.map = map; + setParent(mapdevLogger); + setUseParentHandlers(true); + } + + @Override + public void log(LogRecord record) { + super.log(record instanceof MapLogRecord ? record + : new MapLogRecord(map, record)); + } + + public void log(Level level, Node node, String message) { + log(new MapLogRecord(map, level, node, message)); + } + + public void fine(Node node, String message) { log(Level.FINE, node, message); } + public void info(Node node, String message) { log(Level.INFO, node, message); } + public void warning(Node node, String message) { log(Level.WARNING, node, message); } + public void severe(Node node, String message) { log(Level.SEVERE, node, message); } +} diff --git a/PGM/src/main/java/tc/oc/pgm/map/MapModule.java b/PGM/src/main/java/tc/oc/pgm/map/MapModule.java new file mode 100644 index 0000000..bb2774a --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/map/MapModule.java @@ -0,0 +1,65 @@ +package tc.oc.pgm.map; + +import java.util.Collections; +import java.util.Set; +import java.util.logging.Logger; +import javax.annotation.Nullable; + +import com.google.common.collect.Range; +import net.md_5.bungee.api.chat.BaseComponent; +import org.bukkit.WorldCreator; +import org.jdom2.Document; +import tc.oc.api.docs.virtual.MapDoc; +import tc.oc.pgm.xml.InvalidXMLException; + +/** + * Something that is created at most once for each map, usually by parsing some XML, + * and stored in the {@link MapModuleContext}. + * + * This system is obsolete and may be deprecated soon. An explicit base module type + * is fairly pointless in an injected environment, and creates a lot of unnecessary + * dependencies. + * + * The preferred approach is to create a plain class, bind it as @MapScoped, and @Inject + * only direct dependencies. + */ +public interface MapModule { + + /** + * Get the gamemode implemented by the module, or null if it does not implement a gamemode + */ + default Set getGamemodes(MapModuleContext context) { + return Collections.emptySet(); + } + + /** + * Get the name of the game implemented by this module, or null if it does not implement a game + */ + default @Nullable BaseComponent getGameName(MapModuleContext context) { + return null; + } + + /** + * Return the number of players that this module alone can allow to join a match. + */ + default Range getPlayerLimits() { + return Range.singleton(0); + } + + /** + * Called by {@link StaticMethodMapModuleFactory} + * + * @deprecated Implement a {@link MapModuleFactory} instead + */ + @Deprecated + static MapModule parse(MapModuleContext context, Logger logger, Document doc) throws InvalidXMLException { return null; } + + /** + * Called after all modules have finished parsing and all FeatureReferences + * have been resolved successfully. A module can use this method + * to replace FeatureReferences with FeatureDefinitions. It can also throw + * InvalidXMLExceptions from here if needed e.g. for errors that can't be + * detected until referenced features are available. + */ + default void postParse(MapModuleContext context, Logger logger, Document doc) throws InvalidXMLException {} +} diff --git a/PGM/src/main/java/tc/oc/pgm/map/MapModuleContext.java b/PGM/src/main/java/tc/oc/pgm/map/MapModuleContext.java new file mode 100644 index 0000000..76a991c --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/map/MapModuleContext.java @@ -0,0 +1,133 @@ +package tc.oc.pgm.map; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.Collection; +import java.util.Map; +import javax.inject.Inject; +import javax.inject.Provider; + +import com.google.common.collect.Range; +import com.google.common.hash.HashCode; +import org.jdom2.Document; +import tc.oc.api.docs.SemanticVersion; +import tc.oc.commons.core.util.Lazy; +import tc.oc.pgm.features.FeatureDefinitionContext; +import tc.oc.pgm.map.inject.MapScoped; +import tc.oc.pgm.module.ModuleContext; +import tc.oc.pgm.utils.XMLUtils; +import tc.oc.pgm.xml.InvalidXMLException; +import tc.oc.pgm.xml.Node; +import tc.oc.pgm.xml.UnsupportedMapProtocolException; + +/** + * In addition to storing {@link MapModule}s, this class handles XML parsing from end to end. + */ +public class MapModuleContext extends ModuleContext { + + private final Collection> parsers; + private final FeatureDefinitionContext featureDefinitionContext; + private final MapFilePreprocessor preprocessor; + private final Document xmlDocument; + private final SemanticVersion proto; + private final Path basePath; + private final Provider apiDocumentProvider; + + @Inject MapModuleContext(MapFolder mapFolder, + FeatureDefinitionContext featureDefinitionContext, + MapFilePreprocessor.Factory preprocessorFactory, + Provider apiDocumentProvider, + Collection> parsers) throws InvalidXMLException { + + this.featureDefinitionContext = featureDefinitionContext; + this.apiDocumentProvider = apiDocumentProvider; + this.parsers = parsers; + + try { + this.basePath = mapFolder.getAbsolutePath().toRealPath(); + } catch(IOException e) { + throw new InvalidXMLException("File system error while resolving map folder " + mapFolder); + } + + final Path descriptionFile = mapFolder.getAbsoluteDescriptionFilePath(); + if(!java.nio.file.Files.isRegularFile(descriptionFile)) { + throw new MapNotFoundException(descriptionFile); + } + + this.preprocessor = preprocessorFactory.create(mapFolder.getSource()); + this.xmlDocument = preprocessor.readRootDocument(descriptionFile); + + // verify proto + final Node protoNode = Node.fromRequiredAttr(xmlDocument.getRootElement(), "proto"); + this.proto = XMLUtils.parseSemanticVersion(protoNode); + if(proto.isNewerThan(ProtoVersions.CURRENT)) { + throw new UnsupportedMapProtocolException(protoNode, proto); + } + } + + @Override + public void load() { + asCurrentScope(() -> { + // Create MapModules + super.load(); + + // Call MapParser#parse + parsers.forEach( + parser -> ignoringFailures( + parser.get()::parse + ) + ); + + // Bail out early if there were any errors in the parse phase + if(hasErrors()) return; + + // Resolve references and run validations + addErrors(featureDefinitionContext.postParse()); + + // Run module postParse methods + loadedModules().forEach( + module -> ignoringFailures( + () -> module.postParse(this, logger, xmlDocument) + ) + ); + }); + } + + public Document xmlDocument() { + return xmlDocument; + } + + public MapDocument apiDocument() { + return asCurrentScope(apiDocumentProvider::get); + } + + public Path getBasePath() { + return basePath; + } + + public SemanticVersion getProto() { + return proto; + } + + public FeatureDefinitionContext features() { + return this.featureDefinitionContext; + } + + public Map loadedFiles() { + return preprocessor.getIncludedFiles(); + } + + private final Lazy> playerLimits = Lazy.from(() -> { + int min = 0, max = 0; + for(MapModule module : loadedModules()) { + final Range limits = module.getPlayerLimits(); + min += limits.lowerEndpoint(); + max += limits.upperEndpoint(); + } + return Range.closed(min, max); + }); + + public Range playerLimits() { + return playerLimits.get(); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/map/MapModuleFactory.java b/PGM/src/main/java/tc/oc/pgm/map/MapModuleFactory.java new file mode 100644 index 0000000..b52efab --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/map/MapModuleFactory.java @@ -0,0 +1,11 @@ +package tc.oc.pgm.map; + +/** + * A {@link MapModuleManifest} that serves as its own {@link MapModuleParser}. + */ +public abstract class MapModuleFactory extends MapModuleManifest implements MapModuleParser { + @Override + protected MapModuleParser parser() { + return this; + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/map/MapModuleManifest.java b/PGM/src/main/java/tc/oc/pgm/map/MapModuleManifest.java new file mode 100644 index 0000000..1ca340c --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/map/MapModuleManifest.java @@ -0,0 +1,55 @@ +package tc.oc.pgm.map; + +import java.util.Optional; +import javax.inject.Inject; +import javax.inject.Provider; + +import com.google.inject.MembersInjector; +import tc.oc.commons.core.reflect.Types; +import tc.oc.pgm.map.inject.MapScoped; +import tc.oc.pgm.match.MatchModuleFactory; +import tc.oc.pgm.match.inject.MatchModuleFactoryManifest; +import tc.oc.pgm.module.ModuleLoadException; +import tc.oc.pgm.module.ModuleManifest; + +/** + * Configures a {@link MapModule} that is created by a {@link MapModuleParser}. + * + * {@link MapModuleParser#parse} can return null to omit the module. + */ +public abstract class MapModuleManifest extends ModuleManifest { + + protected MapModuleManifest() { + super(null); + } + + @Override + protected void configure() { + super.configure(); + + // Eagerly acquire the parser + parser = parser(); + + // If the MapModule is also a MatchModuleFactory, configure that as well. + if(Types.isAssignable(MatchModuleFactory.class, type)) { + install(new MatchModuleFactoryManifest(key)); + } + } + + protected abstract MapModuleParser parser(); + + private MapModuleParser parser; + + private @Inject MembersInjector injector; + private @Inject Provider contextProvider; + + @Override + protected Optional provisionModuleWithoutDependencies() throws ModuleLoadException { + final MapModuleContext context = contextProvider.get(); + final M module = parser.parse(context, context.logger(), context.xmlDocument()); + if(module != null) { + injector.injectMembers(module); + } + return Optional.ofNullable(module); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/map/MapModuleParser.java b/PGM/src/main/java/tc/oc/pgm/map/MapModuleParser.java new file mode 100644 index 0000000..93d5261 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/map/MapModuleParser.java @@ -0,0 +1,22 @@ +package tc.oc.pgm.map; + +import java.util.logging.Logger; +import javax.annotation.Nullable; + +import org.jdom2.Document; +import tc.oc.pgm.xml.InvalidXMLException; + +/** + * An interface used by legacy map parsing code + */ +public interface MapModuleParser { + /** + * Create a {@link MapModule} from the given XML document, + * or return null to indicate that the module is not needed. + * + * If a module is returned, it's members are injected automatically, + * which means this method must NOT inject anything into the module + * itself. + */ + @Nullable M parse(MapModuleContext context, Logger logger, Document doc) throws InvalidXMLException; +} diff --git a/PGM/src/main/java/tc/oc/pgm/map/MapNotFoundException.java b/PGM/src/main/java/tc/oc/pgm/map/MapNotFoundException.java new file mode 100644 index 0000000..2ee7424 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/map/MapNotFoundException.java @@ -0,0 +1,20 @@ +package tc.oc.pgm.map; + +import java.nio.file.Path; + +import tc.oc.pgm.xml.InvalidXMLException; + +/** + * Thrown when the main XML file of a loaded map disappears, + * or when no maps are found by the map loader. + */ +public class MapNotFoundException extends InvalidXMLException { + + public MapNotFoundException() { + super("No maps could be loaded"); + } + + public MapNotFoundException(Path path) { + super("Failed to load map from " + path); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/map/MapParserBinder.java b/PGM/src/main/java/tc/oc/pgm/map/MapParserBinder.java new file mode 100644 index 0000000..ac4a216 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/map/MapParserBinder.java @@ -0,0 +1,10 @@ +package tc.oc.pgm.map; + +import com.google.inject.Binder; +import tc.oc.commons.core.inject.SetBinder; + +public class MapParserBinder extends SetBinder { + public MapParserBinder(Binder binder) { + super(binder); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/map/MapProto.java b/PGM/src/main/java/tc/oc/pgm/map/MapProto.java new file mode 100644 index 0000000..aaa9a9b --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/map/MapProto.java @@ -0,0 +1,12 @@ +package tc.oc.pgm.map; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import javax.inject.Qualifier; + +import com.google.inject.BindingAnnotation; + +@Qualifier +@BindingAnnotation +@Retention(RetentionPolicy.RUNTIME) +public @interface MapProto {} diff --git a/PGM/src/main/java/tc/oc/pgm/map/MapRootParser.java b/PGM/src/main/java/tc/oc/pgm/map/MapRootParser.java new file mode 100644 index 0000000..ab35248 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/map/MapRootParser.java @@ -0,0 +1,20 @@ +package tc.oc.pgm.map; + +import tc.oc.pgm.map.inject.MapBinders; +import tc.oc.pgm.map.inject.MapScoped; +import tc.oc.pgm.xml.InvalidXMLException; + +/** + * Something that needs to be invoked once for each map parsed. + * + * Bind this into {@link MapBinders#rootParsers()} and it will be provisioned + * and have it's {@link #parse()} method called once at parse time for each map. + * + * Keep in mind that if this is {@link MapScoped} then it will be stored permanently + * with the map. If the object is not needed after parsing then leave it unscoped, + * and it will be discarded. If it has no map-specific state or dependencies, it + * can be a Singleton. + */ +public interface MapRootParser { + void parse() throws InvalidXMLException; +} diff --git a/PGM/src/main/java/tc/oc/pgm/map/MapSource.java b/PGM/src/main/java/tc/oc/pgm/map/MapSource.java new file mode 100644 index 0000000..54c5e75 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/map/MapSource.java @@ -0,0 +1,128 @@ +package tc.oc.pgm.map; + +import java.io.IOException; +import java.net.URL; +import java.nio.file.FileVisitOption; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import java.util.logging.Logger; +import javax.annotation.Nullable; + +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Iterables; +import tc.oc.commons.core.util.Utils; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +public class MapSource implements Comparable { + + private final String key; + private final @Nullable URL url; + private final Path path; + private final int maxDepth; + private final Set onlyPaths; + private final Set excludedPaths; + private final int priority; // Lowest priority source wins a map name conflict + private final boolean globalIncludes; + + public MapSource(String key, Path path, @Nullable URL url, int maxDepth, Set onlyPaths, Set excludedPaths, int priority, boolean globalIncludes) { + this.globalIncludes = globalIncludes; + checkArgument(path.isAbsolute()); + this.key = key; + this.url = url; + this.path = checkNotNull(path); + this.maxDepth = maxDepth; + this.onlyPaths = checkNotNull(onlyPaths); + this.excludedPaths = checkNotNull(excludedPaths); + this.priority = priority; + } + + @Override + public String toString() { + return getClass().getSimpleName() + + "{key=" + key + + " path=" + getPath().toString() + + "}"; + } + + @Override + public int hashCode() { + return key.hashCode(); + } + + @Override + public boolean equals(Object obj) { + return Utils.equals(MapSource.class, this, obj, that -> this.key().equals(that.key)); + } + + @Override + public int compareTo(MapSource o) { + return Integer.compare(priority, o.priority); + } + + public String key() { + return key; + } + + public boolean hasPriorityOver(MapSource o) { + return compareTo(o) < 0; + } + + public boolean globalIncludes() { + return globalIncludes; + } + + public Path getPath() { + return path; + } + + public @Nullable URL getUrl() { + return url; + } + + protected Set getRootPaths() { + if(onlyPaths.isEmpty()) { + return Collections.singleton(Paths.get("")); + } else { + return onlyPaths; + } + } + + protected boolean isExcluded(Path path) { + path = getPath().relativize(path); + for(Path excludedPath : excludedPaths) { + if(path.startsWith(excludedPath)) return true; + } + return false; + } + + public Set getMapFolders(final Logger logger) throws IOException { + final Set mapFolders = new HashSet<>(); + for(Path root : getRootPaths()) { + int depth = "".equals(root.toString()) ? 0 : Iterables.size(root); + Files.walkFileTree(getPath().resolve(root), ImmutableSet.of(FileVisitOption.FOLLOW_LINKS), maxDepth - depth, new SimpleFileVisitor() { + @Override + public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException { + if(!isExcluded(dir)) { + if(MapFolder.isMapFolder(dir)) { + mapFolders.add(dir); + } + return FileVisitResult.CONTINUE; + } else { + logger.fine("Skipping excluded path " + dir); + return FileVisitResult.SKIP_SUBTREE; + } + } + }); + } + return mapFolders; + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/map/MapmakerPlayerFacet.java b/PGM/src/main/java/tc/oc/pgm/map/MapmakerPlayerFacet.java new file mode 100644 index 0000000..0d026a0 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/map/MapmakerPlayerFacet.java @@ -0,0 +1,48 @@ +package tc.oc.pgm.map; + +import javax.annotation.Nullable; +import javax.inject.Inject; + +import org.bukkit.entity.Player; +import org.bukkit.permissions.PermissionAttachment; +import org.bukkit.plugin.Plugin; +import tc.oc.api.docs.PlayerId; +import tc.oc.api.util.Permissions; +import tc.oc.pgm.match.MatchPlayerFacet; + +/** + * Grant the ocn.mapmaker permission to any authors of the current {@link PGMMap}. + * + * TODO: If we add an isActive mechanism to MatchPlayerFacet, this one should only + * be active for authors. + */ +public class MapmakerPlayerFacet implements MatchPlayerFacet { + + private final Plugin plugin; + private final MapInfo mapInfo; + private final PlayerId playerId; + private final Player player; + + private @Nullable PermissionAttachment permissionAttachment; + + @Inject MapmakerPlayerFacet(Plugin plugin, MapInfo mapInfo, PlayerId playerId, Player player) { + this.plugin = plugin; + this.mapInfo = mapInfo; + this.playerId = playerId; + this.player = player; + } + + @Override + public void enable() { + if(mapInfo.isAuthor(playerId)) { + permissionAttachment = player.addAttachment(plugin, Permissions.MAPMAKER, true); + } + } + + @Override + public void disable() { + if(permissionAttachment != null) { + player.removeAttachment(permissionAttachment); + } + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/map/PGMMap.java b/PGM/src/main/java/tc/oc/pgm/map/PGMMap.java new file mode 100644 index 0000000..abdd020 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/map/PGMMap.java @@ -0,0 +1,109 @@ +package tc.oc.pgm.map; + +import javax.annotation.Nullable; +import javax.inject.Inject; + +import com.google.common.collect.Ordering; +import tc.oc.api.docs.virtual.MapDoc; +import tc.oc.commons.core.inject.MemberInjectingFactory; +import tc.oc.commons.core.util.Utils; +import tc.oc.pgm.map.inject.MapScoped; +import tc.oc.pgm.modules.InfoModule; + +/** + * PGMMap is persistent through matches and represents an "anchor" that so that + * map information can be reloaded easily. + */ +public class PGMMap extends MapDefinition { + /** + * Use a factory to create {@link PGMMap}s so that we can + * bind PGMMap itself in {@link MapScoped}. + */ + public static class Factory { + private final MemberInjectingFactory factory; + + @Inject Factory(MemberInjectingFactory factory) { + this.factory = factory; + } + + public PGMMap create(MapFolder folder) { + return factory.newInstance(folder); + } + } + + private boolean pushed; + + @Inject PGMMap(MapFolder folder) { + super(folder); + } + + public MapSource getSource() { + return getFolder().getSource(); + } + + @Override + public String getName() { + return isLoaded() ? getInfo().name + : super.getName(); + } + + public MapInfo getInfo() { + return getContext().needModule(InfoModule.class).getMapInfo(); + } + + public MapId getId() { + return getInfo().id; + } + + public MapDoc getDocument() { + return getContext().apiDocument(); + } + + @Override + public String toString() { + if(isLoaded()) { + return getClass().getSimpleName() + "{id=" + getId() + " name=" + getName() + "}"; + } else { + return getClass().getSimpleName() + "{" + getName() + " (not loaded)}"; + } + } + + @Override + public boolean equals(Object obj) { + return Utils.equals(PGMMap.class, this, obj, that -> + this.getFolder().equals(that.getFolder()) + ); + } + + @Override + public int hashCode() { + return getFolder().hashCode(); + } + + @Override + public boolean reload() throws MapNotFoundException { + if(super.reload()) { + this.pushed = false; + return true; + } + return false; + } + + public boolean isPushed() { + return pushed; + } + + public void markPushed() { + pushed = true; + } + + public static class DisplayOrder extends Ordering { + @Override + public int compare(@Nullable PGMMap left, @Nullable PGMMap right) { + return Ordering.natural().nullsLast().compare( + left == null ? null : left.getInfo(), + right == null ? null : right.getInfo() + ); + } + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/map/PGMMapConfiguration.java b/PGM/src/main/java/tc/oc/pgm/map/PGMMapConfiguration.java new file mode 100644 index 0000000..08c6acd --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/map/PGMMapConfiguration.java @@ -0,0 +1,99 @@ +package tc.oc.pgm.map; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.logging.Logger; +import javax.inject.Inject; +import javax.inject.Named; + +import com.google.common.collect.ImmutableSet; +import org.bukkit.configuration.Configuration; +import org.bukkit.configuration.ConfigurationSection; +import tc.oc.commons.bukkit.configuration.ConfigUtils; +import tc.oc.commons.core.logging.Loggers; +import tc.oc.minecraft.api.configuration.InvalidConfigurationException; + +public class PGMMapConfiguration implements MapConfiguration { + + private final Logger logger; + private final ConfigurationSection config; + private final PGMMapEnvironment environment; + private final Path serverRoot; + + @Inject PGMMapConfiguration(Loggers loggers, Configuration root, PGMMapEnvironment environment, @Named("serverRoot") Path serverRoot) { + this.logger = loggers.get(getClass()); + this.config = root.getSection("map"); + this.environment = environment; + this.serverRoot = serverRoot; + } + + @Override + public List includePaths() { + return ConfigUtils.getPathList(config, "include-path"); + } + + @Override + public List globalIncludes() { + return ConfigUtils.getPathList(config, "global-includes"); + } + + @Override + public Map environment() { + return environment; + } + + @Override + public boolean autoReload() { + return config.getBoolean("autoreload.enabled", true); + } + + @Override + public boolean reloadWhenError() { + return config.getBoolean("autoreload.reload-when-error", false); + } + + @Override + public List sources() { + logger.fine("Loading map sources..."); + + final List sources = new ArrayList<>(); + final ConfigurationSection sourcesSection = config.getSection("sources"); + + for(String key : sourcesSection.getKeys(false)) { + try { + sources.add(loadSource(key, sourcesSection.needSection(key))); + } catch (InvalidConfigurationException e) { + logger.warning("Failed to parse maps source: " + e.getMessage()); + } + } + + Collections.sort(sources); + logger.fine("Loaded " + sources.size() + " sources"); + return sources; + } + + protected MapSource loadSource(String key, ConfigurationSection section) throws InvalidConfigurationException { + Path sourcePath = ConfigUtils.getPath(section, "path", null); + if(sourcePath != null && !sourcePath.isAbsolute()) { + sourcePath = serverRoot.resolve(sourcePath); + } + if(sourcePath == null || !Files.isDirectory(sourcePath)) { + throw new InvalidConfigurationException("Skipping '" + key + "' because it does not have a valid path"); + } + + final MapSource source = new MapSource(key, + sourcePath, + ConfigUtils.getUrl(section, "url", null), + section.getInt("depth", Integer.MAX_VALUE), + ImmutableSet.copyOf(ConfigUtils.getPathList(section, "only")), + ImmutableSet.copyOf(ConfigUtils.getPathList(section, "exclude")), + section.getInt("priority", 0), + section.getBoolean("global-includes", true)); + logger.fine(" " + source); + return source; + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/map/PGMMapEnvironment.java b/PGM/src/main/java/tc/oc/pgm/map/PGMMapEnvironment.java new file mode 100644 index 0000000..f557493 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/map/PGMMapEnvironment.java @@ -0,0 +1,84 @@ +package tc.oc.pgm.map; + +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import javax.inject.Inject; +import javax.inject.Singleton; + +import com.google.common.collect.Collections2; +import com.google.common.collect.ForwardingMap; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Maps; +import com.google.common.collect.Sets; +import org.bukkit.configuration.Configuration; +import org.bukkit.configuration.ConfigurationSection; +import tc.oc.commons.bukkit.configuration.ConfigUtils; +import tc.oc.commons.core.util.Holidays; + +import static tc.oc.commons.core.util.Holidays.Holiday; +import static com.google.common.base.Preconditions.checkNotNull; + +@Singleton +public class PGMMapEnvironment extends ForwardingMap { + + private final ConfigurationSection config; + private final Map manual = new HashMap<>(); + + @Inject PGMMapEnvironment(Configuration config) { + this.config = checkNotNull(config.getConfigurationSection("environment")); + } + + @Override + protected Map delegate() { + return manual; + } + + public ConfigurationSection permanent() { + return config.getSection("permanent"); + } + + public boolean holidays() { + return config.getBoolean("holidays", true); + } + + @Override + public Boolean get(Object objKey) { + String key = (String) objKey; + + Boolean value = manual.get(key); + if(value != null) return value; + + if(holidays()) { + Boolean holiday = null; + for(Holiday h : Holidays.all()) { + if(key.equals(h.key)) { + holiday = h.isNow(); + break; + } + } + if(holiday != null) return holiday; + } + + return permanent().getBoolean(key); + } + + @Override + public Set keySet() { + return Sets.union(Sets.union(manual.keySet(), Holidays.keys()), permanent().getKeys(false)); + } + + @Override + public Collection values() { + return Collections2.transform(keySet(), this::get); + } + + @Override + public Set> entrySet() { + return ImmutableSet.copyOf(Collections2.transform( + keySet(), + key -> Maps.immutableEntry(key, get(key)) + )); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/map/ParsingMethod.java b/PGM/src/main/java/tc/oc/pgm/map/ParsingMethod.java new file mode 100644 index 0000000..eac4285 --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/map/ParsingMethod.java @@ -0,0 +1,7 @@ +package tc.oc.pgm.map; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +@Retention(RetentionPolicy.RUNTIME) +public @interface ParsingMethod {} diff --git a/PGM/src/main/java/tc/oc/pgm/map/ParsingProvider.java b/PGM/src/main/java/tc/oc/pgm/map/ParsingProvider.java new file mode 100644 index 0000000..e5e010f --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/map/ParsingProvider.java @@ -0,0 +1,19 @@ +package tc.oc.pgm.map; + +import javax.inject.Provider; + +import tc.oc.commons.core.inject.Injection; +import tc.oc.pgm.xml.InvalidXMLException; + +/** + * A {@link Provider} that can throw {@link InvalidXMLException}s + */ +public interface ParsingProvider extends Provider { + + T parse() throws InvalidXMLException; + + @Override + default T get() { + return Injection.wrappingExceptions(this::parse); + } +} diff --git a/PGM/src/main/java/tc/oc/pgm/map/ProtoVersions.java b/PGM/src/main/java/tc/oc/pgm/map/ProtoVersions.java new file mode 100644 index 0000000..bdd035c --- /dev/null +++ b/PGM/src/main/java/tc/oc/pgm/map/ProtoVersions.java @@ -0,0 +1,47 @@ +package tc.oc.pgm.map; + +import tc.oc.api.docs.SemanticVersion; + +public class ProtoVersions { + // Version that fixed the off-by-one region bug + public static final SemanticVersion REGION_FIX_VERSION = new SemanticVersion(1, 3, 1); + + // Version that introduced monument modes + public static final SemanticVersion MODES_IMPLEMENTATION_VERSION = new SemanticVersion(1, 3, 2); + + // First proto to define the way overlapping regions behave + public static final SemanticVersion REGION_PRIORITY_VERSION = new SemanticVersion(1, 3, 3); + + // Wool locations required + public static final SemanticVersion WOOL_LOCATIONS = new SemanticVersion(1, 3, 4); + + // Filters know who owns TNT + public static final SemanticVersion FILTER_OWNED_TNT = new SemanticVersion(1, 3, 5); + + // Move all defining elements out of module xml root + public static final SemanticVersion MODULE_SUBELEMENT_VERSION = new SemanticVersion(1, 3, 6); + + // Everything scores zero points by default + public static final SemanticVersion DEFAULT_SCORES_TO_ZERO = new SemanticVersion(1, 3, 6); + + // Filters/regions/teams always referenced by ID + public static final SemanticVersion FILTER_FEATURES = new SemanticVersion(1, 4, 0); + + // Disallow