Port questmanager service from PE

This commit is contained in:
Kenny 2017-05-02 20:31:40 -04:00
parent ff13d07a7c
commit 87c01f994c
15 changed files with 930 additions and 0 deletions

View File

@ -0,0 +1,69 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.mineplex</groupId>
<artifactId>mineplex-parent</artifactId>
<version>dev-SNAPSHOT</version>
</parent>
<artifactId>mineplex-questmanager</artifactId>
<name>Mineplex.QuestManager</name>
<description>A centralized service that selects daily quests</description>
<properties>
<version.guava>18.0</version.guava>
<version.jline>2.12</version.jline>
<version.spigot>1.8.8-1.9-SNAPSHOT</version.spigot>
</properties>
<dependencies>
<dependency>
<groupId>${project.groupId}</groupId>
<artifactId>mineplex-serverdata</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>${version.guava}</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>jline</groupId>
<artifactId>jline</artifactId>
<version>${version.jline}</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>com.mineplex</groupId>
<artifactId>spigot</artifactId>
<scope>provided</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<configuration>
<minimizeJar>false</minimizeJar>
<transformers>
<transformer
implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>mineplex.quest.daemon.QuestDaemon</mainClass>
</transformer>
</transformers>
</configuration>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@ -0,0 +1,93 @@
package mineplex.quest.client;
import java.util.HashSet;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
import org.bukkit.plugin.java.JavaPlugin;
import com.google.common.collect.ImmutableSet;
import mineplex.quest.client.event.QuestsUpdatedEvent;
import mineplex.quest.common.Quest;
import mineplex.quest.common.QuestSupplier;
import mineplex.quest.common.redis.PubSubChannels;
import mineplex.quest.common.redis.QuestTypeSerializer;
import mineplex.serverdata.redis.messaging.PubSubMessager;
/**
* Provides methods to retrieve currently active quests. Retrieves active quests from the
* centralized quest service via redis.
* <p>
* Intended to be thread-safe.
*/
public class RedisQuestSupplier implements QuestSupplier
{
private final JavaPlugin _plugin;
private final PubSubMessager _pubSub;
private final ReadWriteLock _lock = new ReentrantReadWriteLock();
private final Set<Quest> _quests = new HashSet<>();
public RedisQuestSupplier(JavaPlugin plugin, PubSubMessager pubSub)
{
_plugin = plugin;
_pubSub = pubSub;
// update quests when received
_pubSub.subscribe(PubSubChannels.QUEST_SUPPLIER_CHANNEL, this::updateQuests);
}
private void updateQuests(String channel, String message)
{
_lock.writeLock().lock();
try
{
_quests.clear();
_quests.addAll(deserialize(message));
// notify
_plugin.getServer().getPluginManager().callEvent(new QuestsUpdatedEvent(get()));
}
finally
{
_lock.writeLock().unlock();
}
}
private Set<Quest> deserialize(String json)
{
return QuestTypeSerializer.QUEST_GSON.fromJson(json, QuestTypeSerializer.QUEST_TYPE);
}
@Override
public Set<Quest> get()
{
_lock.readLock().lock();
try
{
return ImmutableSet.copyOf(_quests);
}
finally
{
_lock.readLock().unlock();
}
}
@Override
public Optional<Quest> getById(int uniquePersistentId)
{
_lock.readLock().lock();
try
{
return _quests.stream().filter(q -> q.getUniqueId() == uniquePersistentId).findFirst();
}
finally
{
_lock.readLock().unlock();
}
}
}

View File

@ -0,0 +1,44 @@
package mineplex.quest.client.event;
import java.util.Set;
import org.bukkit.event.Event;
import org.bukkit.event.HandlerList;
import mineplex.quest.common.Quest;
/**
* An event called when the currently active quests are rotated at the end of a day.
*/
public class QuestsUpdatedEvent extends Event
{
private static final HandlerList HANDLERS = new HandlerList();
private final Set<Quest> _to;
public QuestsUpdatedEvent(Set<Quest> to)
{
_to = to;
}
/**
* @return The currently active quests.
*/
public Set<Quest> getActiveQuests()
{
return _to;
}
@Override
public HandlerList getHandlers()
{
return HANDLERS;
}
public static HandlerList getHandlerList()
{
return HANDLERS;
}
}

View File

@ -0,0 +1,43 @@
package mineplex.quest.common;
import mineplex.serverdata.data.Data;
import mineplex.serverdata.data.DataRepository;
/**
* A quest that can be completed by users for a reward.
*/
public interface Quest extends Data
{
/**
* Gets the name of this quest.
*
* @return The name of this quest.
*/
String getName();
/**
* Gets the unique persistent id for this quest. This id will be used to store quests per user
* and <b>should not be changed</b>.
*
* @return The unique persistent id for this quest.
*/
int getUniqueId();
/**
* Gets the {@link QuestRarity} of this quest.
*
* @return The rarity of this quest.
*/
QuestRarity getRarity();
/**
* Get the unique persistent id for this quest as a String. Intended to be used for storage
* within Redis via {@link DataRepository}.
* <p>
* Don't use this to get the quest unique id, use {@link Quest#getUniqueId()} instead.
*
* @return A string version of the unique persistent id for this quest.
*/
@Override
String getDataId();
}

View File

@ -0,0 +1,25 @@
package mineplex.quest.common;
/**
* How rare a quest is. In other words, how often this quest should be chosen.
*/
public enum QuestRarity
{
// TODO fill in actual weights
COMMON(0.5),
RARE(0.3),
LEGENDARY(0.1)
;
private final double _weight;
private QuestRarity(double weight)
{
_weight = weight;
}
public double getWeight()
{
return _weight;
}
}

View File

@ -0,0 +1,29 @@
package mineplex.quest.common;
import java.util.Optional;
import java.util.Set;
import java.util.function.Supplier;
/**
* Provides access to {@link Quest}s tracked by this QuestManager.
*/
public interface QuestSupplier extends Supplier<Set<Quest>>
{
/**
* Get an immutable set containing all of the currently active quests.
*/
@Override
Set<Quest> get();
/**
* Attempts to get the {@link Quest} matching the supplied persistent id.
*
* @param uniquePersistentId The unique id of the quest.
*
* @return An {@link Optional} describing the {@link Quest}, or an empty Optional if none is
* found.
*/
Optional<Quest> getById(int uniquePersistentId);
}

View File

@ -0,0 +1,69 @@
package mineplex.quest.common;
/**
* A centralized list of {@link Quest}s.
* <p>
* When adding new quests they should be given an enum field here & then added to the minecraft
* server plugin in the form of a "tracker" that watches progress per user.
*/
public enum Quests implements Quest
{
// TODO add actual quests
EXAMPLE_QUEST_1("1", 0, QuestRarity.COMMON),
EXAMPLE_QUEST_2("2", 1, QuestRarity.COMMON),
EXAMPLE_QUEST_3("3", 2, QuestRarity.RARE),
EXAMPLE_QUEST_4("4", 3, QuestRarity.LEGENDARY),
EXAMPLE_QUEST_5("5", 4, QuestRarity.COMMON),
;
private final String _name;
private final int _uniqueId;
private final QuestRarity _rarity;
Quests(String name, int uniqueId, QuestRarity rarity)
{
_name = name;
_uniqueId = uniqueId;
_rarity = rarity;
}
@Override
public String getName()
{
return _name;
}
@Override
public QuestRarity getRarity()
{
return _rarity;
}
@Override
public int getUniqueId()
{
return _uniqueId;
}
@Override
public String getDataId()
{
return String.valueOf(_uniqueId);
}
public static Quest fromId(int id)
{
for (Quest q : values())
{
if (q.getUniqueId() == id)
{
return q;
}
}
return null;
}
}

View File

@ -0,0 +1,11 @@
package mineplex.quest.common.redis;
/**
* Provides constants for Quests redis pub sub channels.
*/
public class PubSubChannels
{
public static final String QUEST_SUPPLIER_CHANNEL = "quest-manager";
}

View File

@ -0,0 +1,28 @@
package mineplex.quest.common.redis;
import mineplex.quest.common.Quest;
import mineplex.quest.common.Quests;
import mineplex.serverdata.Region;
import mineplex.serverdata.redis.RedisDataRepository;
import mineplex.serverdata.servers.ConnectionData;
/**
* A {@link RedisDataRepository} that can serialize & deserialize (and thus store & retrieve from
* redis) Quest instances.
*/
public class QuestRedisDataRepository extends RedisDataRepository<Quest>
{
public QuestRedisDataRepository(ConnectionData writeConn, ConnectionData readConn, Region region,
String elementLabel)
{
super(writeConn, readConn, region, Quest.class, elementLabel);
}
@Override
protected Quest deserialize(String json)
{
return Quests.fromId(Integer.parseInt(json));
}
}

View File

@ -0,0 +1,34 @@
package mineplex.quest.common.redis;
import java.lang.reflect.Type;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;
import java.util.stream.Collectors;
import com.google.gson.Gson;
import com.google.gson.JsonDeserializationContext;
import com.google.gson.JsonDeserializer;
import com.google.gson.JsonElement;
import com.google.gson.JsonParseException;
import mineplex.quest.common.Quest;
import mineplex.quest.common.Quests;
/**
* An implementation of a {@link JsonDeserializer} intended for use in {@link Gson}. Deserializes a
* {@link JsonElement} String into a Set<Quest>.
*/
public class QuestTypeDeserialiazer implements JsonDeserializer<Set<Quest>>
{
@Override
public Set<Quest> deserialize(JsonElement json, Type typeOfT,
JsonDeserializationContext context) throws JsonParseException
{
String[] split = json.getAsString().split(QuestTypeSerializer.SEPARATOR);
return Arrays.stream(split).map(questId -> Quests.fromId(Integer.valueOf(questId)))
.collect(Collectors.toCollection(HashSet::new));
}
}

View File

@ -0,0 +1,44 @@
package mineplex.quest.common.redis;
import java.lang.reflect.Type;
import java.util.Set;
import java.util.stream.Collectors;
import com.google.common.base.Joiner;
import com.google.common.reflect.TypeToken;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonElement;
import com.google.gson.JsonPrimitive;
import com.google.gson.JsonSerializationContext;
import com.google.gson.JsonSerializer;
import mineplex.quest.common.Quest;
/**
* An implementation of a {@link JsonSerializer} intended for use in {@link Gson}. Serializes a
* Set<Quest> into a {@link JsonElement} String.
*/
public class QuestTypeSerializer implements JsonSerializer<Set<Quest>>
{
@SuppressWarnings("serial")
public static final Type QUEST_TYPE = new TypeToken<Set<Quest>>(){}.getType();
public static final Gson QUEST_GSON = new GsonBuilder()
.registerTypeAdapter(QuestTypeSerializer.QUEST_TYPE, new QuestTypeDeserialiazer())
.registerTypeAdapter(QuestTypeSerializer.QUEST_TYPE, new QuestTypeSerializer())
.create();
public static final String SEPARATOR = ",";
@Override
public JsonElement serialize(Set<Quest> src, Type typeOfSrc, JsonSerializationContext context)
{
StringBuilder builder = new StringBuilder();
Joiner.on(SEPARATOR).appendTo(builder,
src.stream().map(Quest::getDataId).collect(Collectors.toSet()));
return new JsonPrimitive(builder.toString());
}
}

View File

@ -0,0 +1,53 @@
package mineplex.quest.common.util;
import java.util.Map;
import java.util.NavigableMap;
import java.util.Random;
import java.util.TreeMap;
import java.util.concurrent.ThreadLocalRandom;
/**
* Provides random, weighted access to a collection of elements.
*
* @param <E> The generic type parameter of the elements.
*/
public class RandomCollection<E>
{
private final NavigableMap<Double, E> _map = new TreeMap<Double, E>();
private final Random _random;
private double total = 0;
public RandomCollection(Random random)
{
_random = random;
}
public RandomCollection()
{
this(ThreadLocalRandom.current());
}
public void addAll(Map<E, Double> values)
{
values.forEach(this::add);
}
public void add(E result, double weight)
{
if (weight <= 0)
{
return;
}
total += weight;
_map.put(total, result);
}
public E next()
{
double value = _random.nextDouble() * total;
return _map.ceilingEntry(value).getValue();
}
}

View File

@ -0,0 +1,160 @@
package mineplex.quest.daemon;
import java.io.IOException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.logging.FileHandler;
import java.util.logging.Formatter;
import java.util.logging.LogRecord;
import java.util.logging.Logger;
import org.fusesource.jansi.AnsiConsole;
import com.google.common.base.Throwables;
import mineplex.serverdata.redis.messaging.PubSubJedisClient;
import mineplex.serverdata.redis.messaging.PubSubRouter;
import mineplex.serverdata.servers.ServerManager;
import jline.console.ConsoleReader;
/**
* Entry point for a {@link QuestManager} service.
*/
public class QuestDaemon
{
public static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("MM:dd:yyyy HH:mm:ss");
private static final Logger _logger = Logger.getLogger("QuestManager");
static
{
FileHandler fileHandler;
try
{
fileHandler = new FileHandler("monitor.log", true);
fileHandler.setFormatter(new Formatter()
{
@Override
public String format(LogRecord record)
{
return record.getMessage() + "\n";
}
});
_logger.addHandler(fileHandler);
_logger.setUseParentHandlers(false);
}
catch (SecurityException | IOException e)
{
log("COuld not initialize log file!");
log(e);
}
}
private volatile boolean _alive = true;
private QuestManager _questManager;
public static void main(String[] args)
{
try
{
new QuestDaemon().run();
System.exit(0);
}
catch (Throwable t)
{
log("Error in startup/console thread.");
log(t);
System.exit(1);
}
}
private void run() throws Exception
{
log("Starting QuestDaemon...");
_questManager = new QuestManager(
new PubSubRouter(new PubSubJedisClient(ServerManager.getMasterConnection(),
ServerManager.getSlaveConnection())));
_questManager.start();
Runtime.getRuntime().addShutdownHook(new Thread(() -> _questManager.onShutdown()));
AnsiConsole.systemInstall();
ConsoleReader consoleReader = new ConsoleReader();
consoleReader.setExpandEvents(false);
String command;
while (_alive && (command = consoleReader.readLine(">")) != null)
{
try
{
if (command.equals("help"))
{
log("QuestManager commands:");
log("stop: Shuts down this QuestManager instance.");
log("clearactivequests: Clears active quests. New ones will be selected on this instance's next iteration.");
log("clearrecentrequests: Clear recently selected quests. This effectively allows any quest to be set to active, even ones selected within the past few days.");
}
if (command.contains("stop"))
{
stopCommand();
}
else if (command.contains("clearactivequests"))
{
clearQuestsCommand();
}
else if (command.contains("clearrecentquests"))
{
}
}
catch (Throwable t)
{
log("Exception encountered while executing command " + command + ": "
+ t.getMessage());
log(t);
}
}
}
private void stopCommand() throws Exception
{
log("Shutting down QuestDaemon...");
System.exit(0);
}
private void clearQuestsCommand()
{
_questManager.clearActiveQuests();
log("Cleared active quests. New ones will be selected on this instance's next iteration.");
}
public static void log(String message)
{
log(message, false);
}
public static void log(Throwable t)
{
log(Throwables.getStackTraceAsString(t));
}
public static void log(String message, boolean fileOnly)
{
_logger.info("[" + DATE_FORMAT.format(new Date()) + "] " + message);
if (!fileOnly)
{
System.out.println("[" + DATE_FORMAT.format(new Date()) + "] " + message);
}
}
public static String getLogPrefix(Object loggingClass)
{
return "[" + DATE_FORMAT.format(new Date()) + "] ";
}
}

View File

@ -0,0 +1,227 @@
package mineplex.quest.daemon;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import mineplex.quest.common.Quest;
import mineplex.quest.common.QuestRarity;
import mineplex.quest.common.Quests;
import mineplex.quest.common.redis.PubSubChannels;
import mineplex.quest.common.redis.QuestRedisDataRepository;
import mineplex.quest.common.redis.QuestTypeSerializer;
import mineplex.quest.common.util.RandomCollection;
import mineplex.serverdata.Region;
import mineplex.serverdata.data.DataRepository;
import mineplex.serverdata.redis.messaging.PubSubMessager;
import mineplex.serverdata.servers.ServerManager;
/**
* A centralized service that handles setting {@link Quest} instances as active for servers to
* display to players. Uses redis to notify servers of active quests changes and to store recently
* selected quests.
* <p>
* Uses {@link QuestRarity} to randomly select active quests based on relative weight.
*/
public class QuestManager extends Thread
{
private static final int RECENT_QUESTS_EXPIRE_SECONDS = (int) TimeUnit.DAYS.toSeconds(5);
private static final long SLEEP_MILLIS = TimeUnit.MINUTES.toMillis(1);
private static final int NUM_ACTIVE_QUESTS = 5;
private static final ZoneId EST_TIMEZONE = ZoneId.of("America/New_York");
private static final String LAST_UPDATE_FILE = "last-quest-update.dat";
// all quests, mapped from rarity weight to quest
private final RandomCollection<Quest> _quests = new RandomCollection<>();
// currently active quests
private final Set<Quest> _activeQuests = Collections.synchronizedSet(new HashSet<>());
// redis pubsub messager, used to publish active quests to servers
private final PubSubMessager _pubSub;
// redis repository to track recently selected quests, to prevent selecting a quest too soon
// after it's been active
private final DataRepository<Quest> _recentlySelectedQuestsRepo;
// the current date, e.g. the last date active quests were updated
private volatile LocalDate _currentDate;
// whether this instance is running or not
private volatile boolean _alive = true;
public QuestManager(PubSubMessager pubSub)
{
_pubSub = pubSub;
_recentlySelectedQuestsRepo = new QuestRedisDataRepository(
ServerManager.getMasterConnection(),
ServerManager.getSlaveConnection(), Region.currentRegion(), "recently-selected-quests");
_quests.addAll(Arrays.stream(Quests.values())
.collect(Collectors.toMap(q -> q, q -> q.getRarity().getWeight())));
loadLastActiveQuests();
if (_activeQuests.size() > 0)
{
QuestDaemon.log("Active quests loaded from file:");
_activeQuests.forEach(quest -> QuestDaemon.log(((Quests) quest).name()));
}
}
/**
* Loads last set active quests & the date they were set to active from a flat file, if the file
* exists.
*/
private void loadLastActiveQuests()
{
File file = new File(LAST_UPDATE_FILE);
if (!file.exists())
{
_currentDate = LocalDate.now(EST_TIMEZONE);
return;
}
try
{
List<String> lines = Files.readAllLines(Paths.get(file.getAbsolutePath()));
_currentDate = LocalDate.parse(lines.get(0));
if (lines.size() > 1)
{
for (int i = 1; i < lines.size(); i++)
{
Quest quest = Quests.fromId(Integer.parseInt(lines.get(i)));
_activeQuests.add(quest);
}
}
}
catch (IOException e)
{
QuestDaemon.log(
"Exception encountered while loading last updated quests: " + e.getMessage());
QuestDaemon.log(e);
_currentDate = LocalDate.now(EST_TIMEZONE);
}
}
@Override
public void run()
{
try
{
while (_alive)
{
LocalDate now = LocalDate.now(EST_TIMEZONE);
// check if date has changed; if so we need to choose new quests
if (_currentDate.isBefore(now) || _activeQuests.isEmpty())
{
QuestDaemon.log("Updating active quests...");
_currentDate = now;
// select new quests
selectRandomQuests();
// publish new quests
_pubSub.publish(PubSubChannels.QUEST_SUPPLIER_CHANNEL, serialize(_activeQuests));
}
// take a small break, important so CPU isn't constantly running
Thread.sleep(SLEEP_MILLIS);
}
}
catch (InterruptedException e)
{
QuestDaemon.log(
"Exception encountered updating active quests repo: " + e.getMessage());
QuestDaemon.log(e);
}
}
/**
* Called on shutdown of this service. Writes the date quests were last updated to a file, so
* this service will know whether to update them or not on the next startup. This is all that's
* needed to keep active quests in a sane state because they are stored in redis.
*/
public void onShutdown()
{
_alive = false;
try
{
File file = new File(LAST_UPDATE_FILE);
if (!file.exists())
{
file.createNewFile();
}
List<String> lines = new ArrayList<>();
// add active quests date
lines.add(_currentDate.toString());
// add currently active quests
_activeQuests.stream().map(Quest::getDataId).forEach(lines::add);
Files.write(Paths.get(file.getAbsolutePath()), lines);
}
catch (IOException e)
{
QuestDaemon.log("Exception encountered saving " + LAST_UPDATE_FILE + " file: " + e.getMessage());
QuestDaemon.log(e);
}
}
public void clearActiveQuests()
{
_activeQuests.clear();
}
private void selectRandomQuests()
{
if (!_activeQuests.isEmpty())
{
_activeQuests.clear();
}
while (_activeQuests.size() < NUM_ACTIVE_QUESTS)
{
Quest q = _quests.next();
// select random weighted quest, ignore those recently selected
while (_activeQuests.contains(q)
&& _recentlySelectedQuestsRepo.elementExists(q.getDataId()))
{
// quest is already active or it's been active recently
q = _quests.next();
}
// add active quest
_activeQuests.add(q);
QuestDaemon.log("Selected quest: " + ((Quests) q).name());
// flag quest as recently selected
_recentlySelectedQuestsRepo.addElement(q, RECENT_QUESTS_EXPIRE_SECONDS);
}
}
private String serialize(Set<Quest> quests)
{
return QuestTypeSerializer.QUEST_GSON.toJson(quests, QuestTypeSerializer.QUEST_TYPE);
}
}

View File

@ -43,6 +43,7 @@
<module>mavericks-review-hub</module>
<module>mineplex-game-gemhunters</module>
<module>mineplex-google-sheets</module>
<module>mineplex-questmanager</module>
</modules>
<repositories>