diff --git a/.gitignore b/.gitignore index 6eefd4d0..9063550e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,5 +6,4 @@ *.sphinx build out -discord-impl/src/main/resources/token.txt api/src/main/resources \ No newline at end of file diff --git a/api/src/main/java/net/starype/quiz/api/database/ByteEntryUpdater.java b/api/src/main/java/net/starype/quiz/api/database/ByteEntryUpdater.java index 5c8b5537..9da127d9 100644 --- a/api/src/main/java/net/starype/quiz/api/database/ByteEntryUpdater.java +++ b/api/src/main/java/net/starype/quiz/api/database/ByteEntryUpdater.java @@ -4,6 +4,7 @@ import net.starype.quiz.api.util.CheckSum; import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; import java.util.Collection; import java.util.Set; @@ -38,7 +39,7 @@ public CheckSum computeCheckSum() { @Override public Set generateNewEntries(DatabaseEntryFactory factory) { - String text = new String(data); + String text = new String(data, StandardCharsets.UTF_8); return QuestionParser.getDatabaseEntries(text, factory); } } diff --git a/api/src/main/java/net/starype/quiz/api/event/GameUpdatableHandler.java b/api/src/main/java/net/starype/quiz/api/event/GameUpdatableHandler.java index 53e4f92c..a40f7d81 100644 --- a/api/src/main/java/net/starype/quiz/api/event/GameUpdatableHandler.java +++ b/api/src/main/java/net/starype/quiz/api/event/GameUpdatableHandler.java @@ -37,4 +37,9 @@ public void runAllEvents() { this.lastMillis = currentTime; eventsList.forEach((updatable -> updatable.update(deltaMillis))); } + + @Override + public void reset() { + this.lastMillis = System.currentTimeMillis(); + } } diff --git a/api/src/main/java/net/starype/quiz/api/event/UpdatableHandler.java b/api/src/main/java/net/starype/quiz/api/event/UpdatableHandler.java index 143a58e7..de09c87d 100644 --- a/api/src/main/java/net/starype/quiz/api/event/UpdatableHandler.java +++ b/api/src/main/java/net/starype/quiz/api/event/UpdatableHandler.java @@ -25,4 +25,11 @@ public interface UpdatableHandler { * @see Updatable */ void runAllEvents(); + + /** + * Resets the last millis value. + * Should be called at the beginning of each new round, since delta times are not calculated between rounds + * and would thus cause a huge time gap at the beginning of each round. + */ + void reset(); } diff --git a/api/src/main/java/net/starype/quiz/api/game/GuessCounter.java b/api/src/main/java/net/starype/quiz/api/game/GuessCounter.java index 399fab6b..8ac68d52 100644 --- a/api/src/main/java/net/starype/quiz/api/game/GuessCounter.java +++ b/api/src/main/java/net/starype/quiz/api/game/GuessCounter.java @@ -30,4 +30,8 @@ public int getPlayerGuess(IDHolder holder) { public boolean maxReached(IDHolder holder) { return getPlayerGuess(holder) < maxGuesses; } + + public boolean isEmpty() { + return guessesPerPlayer.isEmpty(); + } } diff --git a/api/src/main/java/net/starype/quiz/api/game/QuizTimer.java b/api/src/main/java/net/starype/quiz/api/game/QuizTimer.java index 9bd14d94..3044996b 100644 --- a/api/src/main/java/net/starype/quiz/api/game/QuizTimer.java +++ b/api/src/main/java/net/starype/quiz/api/game/QuizTimer.java @@ -7,16 +7,22 @@ import java.time.Instant; import java.util.concurrent.TimeUnit; -public class QuizTimer extends GameUpdatable { +public class QuizTimer extends GameUpdatable implements EventListener { private long time; - private TimeUnit unit; + private final long shortenedTime; + private final TimeUnit unit; private Instant startingInstant; private Instant currentInstant; private UpdatableHandler updatableHandler; public QuizTimer(TimeUnit unit, long time) { + this(unit, time, time); + } + + public QuizTimer(TimeUnit unit, long time, long shortenedTime) { this.unit = unit; this.time = time; + this.shortenedTime = shortenedTime; } @Override @@ -37,7 +43,25 @@ public void update(long deltaMillis) { } } + @Override public void shutDown() { updatableHandler.unregisterEvent(this); } + + public long millisLeft() { + return unit.toMillis(time) - Duration.between(startingInstant, currentInstant).toMillis(); + } + + private void shortenRemaining() { + if(millisLeft() < unit.toMillis(shortenedTime)) { + return; + } + this.time = shortenedTime; + this.startingInstant = Instant.now(); + } + + @Override + public void onNotified() { + shortenRemaining(); + } } diff --git a/api/src/main/java/net/starype/quiz/api/game/SimpleGame.java b/api/src/main/java/net/starype/quiz/api/game/SimpleGame.java index 93258c8c..a84f6bee 100644 --- a/api/src/main/java/net/starype/quiz/api/game/SimpleGame.java +++ b/api/src/main/java/net/starype/quiz/api/game/SimpleGame.java @@ -30,11 +30,11 @@ public class SimpleGame implements QuizGame { private ServerGate gate; - private Queue rounds; - private Collection> players; + private final Queue rounds; + private final Collection> players; private final AtomicBoolean paused; private boolean waitingForNextRound; - private UpdatableHandler updatableHandler = new GameUpdatableHandler(); + private final UpdatableHandler updatableHandler = new GameUpdatableHandler(); private boolean over; public SimpleGame(Queue rounds, Collection> players) { @@ -96,7 +96,9 @@ public boolean nextRound() { return true; } + waitingForNextRound = false; paused.set(false); + updatableHandler.reset(); startHead(false); return true; } @@ -215,6 +217,9 @@ public void removePlayer(Object playerId) { .filter(player -> player.getId().equals(playerId)) .findAny(); optPlayer.ifPresent(players::remove); + if(!rounds.isEmpty() && !paused.get()) { + rounds.element().checkEndOfRound(); + } } @Override @@ -237,8 +242,8 @@ private Player findHolder(Object id) { .orElseThrow(error); } - protected Collection> getPlayers() { - return players; + protected QuizRound getCurrentRound() { + return rounds.peek(); } public boolean isWaitingForNextRound() { diff --git a/api/src/main/java/net/starype/quiz/api/parser/QuestionParser.java b/api/src/main/java/net/starype/quiz/api/parser/QuestionParser.java index 497ab54a..0bf8d426 100644 --- a/api/src/main/java/net/starype/quiz/api/parser/QuestionParser.java +++ b/api/src/main/java/net/starype/quiz/api/parser/QuestionParser.java @@ -61,7 +61,7 @@ public static Question parse(ReadableRawMap config) { return new DefaultQuestion.Builder() .withAnswerEvaluator(evaluator) .withRawText(rawText) - .withRawAnswer(String.join(",", rawAnswers)) + .withRawAnswer(String.join(", ", rawAnswers)) .withTags(tags) .withDifficulty(difficulty) .build(); diff --git a/api/src/main/java/net/starype/quiz/api/player/Player.java b/api/src/main/java/net/starype/quiz/api/player/Player.java index 13902e5f..5271e440 100644 --- a/api/src/main/java/net/starype/quiz/api/player/Player.java +++ b/api/src/main/java/net/starype/quiz/api/player/Player.java @@ -5,11 +5,11 @@ public class Player implements IDHolder, Comparable> { - private String username; - private String nickname; - private Score score; - private T id; - private Collection> children = new ArrayList<>(); + private final String username; + private final String nickname; + private final Score score; + private final T id; + private final Collection> children = new ArrayList<>(); public Player(T id, String username, String nickname) { this.username = username; diff --git a/api/src/main/java/net/starype/quiz/api/round/TimedRaceRoundFactory.java b/api/src/main/java/net/starype/quiz/api/round/TimedRaceRoundFactory.java index c3f3e60d..91c20e75 100644 --- a/api/src/main/java/net/starype/quiz/api/round/TimedRaceRoundFactory.java +++ b/api/src/main/java/net/starype/quiz/api/round/TimedRaceRoundFactory.java @@ -33,6 +33,7 @@ public QuizRound create(Question question, int maxGuesses, .addEvent(quizTimer) .withEndingCondition(timeOutEnding) .withPlayerEligibility(new MaxGuess(counter)) + .addEvent(quizTimer) .build(); quizTimer.addEventListener(round::checkEndOfRound); diff --git a/api/src/main/java/net/starype/quiz/api/server/ServerGate.java b/api/src/main/java/net/starype/quiz/api/server/ServerGate.java index 9a8081f6..d6a5bb6e 100644 --- a/api/src/main/java/net/starype/quiz/api/server/ServerGate.java +++ b/api/src/main/java/net/starype/quiz/api/server/ServerGate.java @@ -27,8 +27,8 @@ */ public class ServerGate { - private GameServer server; - private T game; + private final GameServer server; + private final T game; /** * Initialize a gate with the given server and no game. diff --git a/discord-impl/src/main/java/net/starype/quiz/discordimpl/command/CreateLobbyCommand.java b/discord-impl/src/main/java/net/starype/quiz/discordimpl/command/CreateLobbyCommand.java index 17b58423..319fd8ab 100644 --- a/discord-impl/src/main/java/net/starype/quiz/discordimpl/command/CreateLobbyCommand.java +++ b/discord-impl/src/main/java/net/starype/quiz/discordimpl/command/CreateLobbyCommand.java @@ -26,7 +26,7 @@ public void execute(CommandContext context) { Map, String> stopConditions = createStopConditions( context.getGameList(), context.getLobbyList(), - playerId, + author, author.getEffectiveName()); Message message = context.getMessage(); @@ -42,19 +42,19 @@ public void execute(CommandContext context) { } private Map, String> createStopConditions( - GameList gameList, LobbyList lobbyList, String authorId, String nickName) { + GameList gameList, LobbyList lobbyList, Member author, String nickName) { Map, String> conditions = new LinkedHashMap<>(); conditions.put( - () -> lobbyList.findByPlayer(authorId).isPresent(), + () -> lobbyList.findByPlayer(author.getId()).isPresent(), nickName + ", you are already in a lobby"); conditions.put( - () -> gameList.isPlaying(authorId), + () -> gameList.isPlaying(author), nickName + ", you are already playing a game"); conditions.put( - () -> !lobbyLimiter.register(authorId.hashCode()), + () -> !lobbyLimiter.register(author.hashCode()), "Error: Cannot create a new lobby as the maximum number of lobbies has been reached"); diff --git a/discord-impl/src/main/java/net/starype/quiz/discordimpl/command/ForceNextRoundCommand.java b/discord-impl/src/main/java/net/starype/quiz/discordimpl/command/ForceNextRoundCommand.java index e4f13cd7..72ae9b53 100644 --- a/discord-impl/src/main/java/net/starype/quiz/discordimpl/command/ForceNextRoundCommand.java +++ b/discord-impl/src/main/java/net/starype/quiz/discordimpl/command/ForceNextRoundCommand.java @@ -1,5 +1,6 @@ package net.starype.quiz.discordimpl.command; +import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.Message; import net.starype.quiz.discordimpl.game.DiscordQuizGame; import net.starype.quiz.discordimpl.game.GameList; @@ -12,28 +13,31 @@ public class ForceNextRoundCommand implements QuizCommand { @Override public void execute(CommandContext context) { GameList gameList = context.getGameList(); - String playerId = context.getAuthor().getId(); + Member player = context.getAuthor(); Message message = context.getMessage(); - Map, String> conditions = createStopConditions(gameList, playerId); + Map, String> conditions = createStopConditions(gameList, player); if(StopConditions.shouldStop(conditions, context.getChannel(), message)) { return; } - DiscordQuizGame game = gameList.getFromPlayer(playerId).get(); + DiscordQuizGame game = gameList.getFromPlayer(player).get(); game.addLog(message.getId()); game.nextRound(); message.addReaction("\uD83D\uDC4D").queue(); } - public static Map, String> createStopConditions(GameList gameList, String authorId) { + public static Map, String> createStopConditions(GameList gameList, Member author) { Map, String> conditions = NextRoundCommand.createStopConditions( gameList, - authorId + author ); conditions.put( - () -> !gameList.getFromPlayer(authorId).get().isAuthor(authorId), + () -> gameList.getFromPlayer(author).isEmpty(), + "You need to be in a game to use this"); + conditions.put( + () -> !gameList.getFromPlayer(author).get().isAuthor(author.getId()), "Only the game creator can use this command"); return conditions; } diff --git a/discord-impl/src/main/java/net/starype/quiz/discordimpl/command/GenerateDatabaseCommand.java b/discord-impl/src/main/java/net/starype/quiz/discordimpl/command/GenerateDatabaseCommand.java index bbc4af15..3f55dcec 100644 --- a/discord-impl/src/main/java/net/starype/quiz/discordimpl/command/GenerateDatabaseCommand.java +++ b/discord-impl/src/main/java/net/starype/quiz/discordimpl/command/GenerateDatabaseCommand.java @@ -46,7 +46,7 @@ public void execute(CommandContext context) { } private static Optional generateFile(String urlName, TextChannel channel) { - Collection updaters = InputUtils.loadEntryUpdaters(urlName, channel); + Collection updaters = InputUtils.loadEntryUpdatersFromUrl(urlName, channel); AtomicReference output = new AtomicReference<>(); SerializedIO serializedIO = new ByteSerializedIO(new byte[0], output); TrackedDatabase db = new QuestionDatabase(updaters, serializedIO, false); diff --git a/discord-impl/src/main/java/net/starype/quiz/discordimpl/command/InfoCommand.java b/discord-impl/src/main/java/net/starype/quiz/discordimpl/command/InfoCommand.java new file mode 100644 index 00000000..e04a36e2 --- /dev/null +++ b/discord-impl/src/main/java/net/starype/quiz/discordimpl/command/InfoCommand.java @@ -0,0 +1,96 @@ +package net.starype.quiz.discordimpl.command; + +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.TextChannel; +import net.starype.quiz.discordimpl.game.DiscordQuizGame; +import net.starype.quiz.discordimpl.game.GameList; +import net.starype.quiz.discordimpl.game.GameLobby; +import net.starype.quiz.discordimpl.game.LobbyList; +import net.starype.quiz.discordimpl.util.MessageUtils; + +import java.awt.*; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.function.Supplier; + +public class InfoCommand implements QuizCommand { + + @Override + public void execute(CommandContext context) { + + GameList gameList = context.getGameList(); + LobbyList lobbyList = context.getLobbyList(); + TextChannel channel = context.getChannel(); + Member member = context.getAuthor(); + + Map, String> conditions = createStopConditions(lobbyList, gameList, member); + if(StopConditions.shouldStop(conditions, channel, context.getMessage())) { + return; + } + + Optional lobby = lobbyList.findByPlayer(member.getId()); + if(lobby.isPresent()) { + displayLobbyInfo(lobby.get(), channel); + } else { + displayGameInfo(gameList.getFromPlayer(member).get(), channel); + } + } + + private static void displayLobbyInfo(GameLobby lobby, TextChannel channel) { + EmbedBuilder builder = new EmbedBuilder(); + builder.setColor(Color.CYAN); + + builder.addField("lobby ID", lobby.getName(), false); + builder.addBlankField(false); + builder.addField("Players", String.join(", ", lobby.retrieveNames()), false); + builder.addField("Question count", String.valueOf(lobby.questionCount()), false); + + MessageUtils.sendAndTrack(builder.build(), channel, lobby); + } + + private static void displayGameInfo(DiscordQuizGame game, TextChannel channel) { + EmbedBuilder builder = new EmbedBuilder(); + builder.setColor(Color.ORANGE); + + if(game.isOutOfRounds() && game.isWaitingForNextRound()) { + builder.addField("Game Status", "Waiting to display results", false); + builder.addBlankField(false); + builder.addField("Not ready yet", String.join(", ", game.haveNotVoted()), false); + } + else if(!game.hasRoundStarted()) { + builder.addField("Game Status", "Waiting for next round", false); + builder.addBlankField(false); + builder.addField("Not ready yet", String.join(", ", game.haveNotVoted()), false); + } else if(!game.isCurrentRoundFinished()){ + builder.addField("Game Status", "Round in progress", false); + builder.addBlankField(false); + builder.addField("Waiting for", String.join(", ", game.waitingFor()), false); + builder.addField("Have answered", String.join(", ", game.haveAnswered()), false); + } + + channel.sendMessageEmbeds(builder.build()) + .map(Message::getId) + .queue(game::addLog); + } + + private static Map, String> createStopConditions(LobbyList lobbyList, GameList gameList, Member author) { + Map, String> conditions = new LinkedHashMap<>(); + conditions.put( + () -> lobbyList.findByPlayer(author.getId()).isEmpty() && gameList.getFromPlayer(author).isEmpty(), + "You must belong to either a lobby or a game"); + return conditions; + } + + @Override + public String getName() { + return "info"; + } + + @Override + public String getDescription() { + return "Get relevant information on your lobby/game"; + } +} diff --git a/discord-impl/src/main/java/net/starype/quiz/discordimpl/command/KickPlayerCommand.java b/discord-impl/src/main/java/net/starype/quiz/discordimpl/command/KickPlayerCommand.java new file mode 100644 index 00000000..f9f6c486 --- /dev/null +++ b/discord-impl/src/main/java/net/starype/quiz/discordimpl/command/KickPlayerCommand.java @@ -0,0 +1,70 @@ +package net.starype.quiz.discordimpl.command; + +import net.dv8tion.jda.api.entities.*; +import net.dv8tion.jda.api.interactions.commands.CommandInteraction; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.CommandData; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; +import net.starype.quiz.discordimpl.game.DiscordQuizGame; +import net.starype.quiz.discordimpl.game.GameList; +import net.starype.quiz.discordimpl.util.MessageUtils; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.function.Supplier; + +public class KickPlayerCommand implements QuizCommand { + + @Override + public void execute(CommandContext context) { + + Member author = context.getAuthor(); + TextChannel channel = context.getChannel(); + GameList gameList = context.getGameList(); + String[] args = context.getArgs(); + + Map, String> conditions = createStopConditions(gameList, author, args, channel); + if(StopConditions.shouldStop(conditions, channel, context.getMessage())) { + return; + } + + User target = channel.getGuild().getMemberByTag(args[0]).getUser(); + DiscordQuizGame game = gameList.getFromPlayer(author).get(); + game.removePlayer(target.getId()); + game.checkEndOfRound(); + + channel.sendMessage("Player successfully excluded from the game") + .map(Message::getId) + .queue(game::addLog, null); + } + + + @Override + public String getName() { + return "kick"; + } + + @Override + public String getDescription() { + return "kick a player from a game"; + } + + private static Map, String> createStopConditions(GameList gameList, + Member author, String[] args, TextChannel channel) { + Map, String> conditions = new LinkedHashMap<>(); + conditions.put( + () -> gameList.getFromPlayer(author).isEmpty(), + "You must be in a game to use this"); + conditions.put( + () -> !gameList.getFromPlayer(author).get().isAuthor(author.getId()), + "You must be the other of the game to use this"); + conditions.put( + () -> args.length != 1, + "You need to tag the member you want to kick"); + + conditions.put( + () -> channel.getGuild().getMemberByTag(args[0]) == null, + "Invalid selected member"); + return conditions; + } +} diff --git a/discord-impl/src/main/java/net/starype/quiz/discordimpl/command/LeaveCommand.java b/discord-impl/src/main/java/net/starype/quiz/discordimpl/command/LeaveCommand.java index e3f92e6f..3d7ecffd 100644 --- a/discord-impl/src/main/java/net/starype/quiz/discordimpl/command/LeaveCommand.java +++ b/discord-impl/src/main/java/net/starype/quiz/discordimpl/command/LeaveCommand.java @@ -19,20 +19,20 @@ public void execute(CommandContext context) { LobbyList lobbyList = context.getLobbyList(); TextChannel channel = context.getChannel(); - Map, String> stopConditions = createStopConditions(gameList, lobbyList, authorId); + Map, String> stopConditions = createStopConditions(gameList, lobbyList, author); if(StopConditions.shouldStop(stopConditions, channel, context.getMessage())) { return; } - gameList.getFromPlayer(authorId).ifPresent(game -> game.removePlayer(authorId)); + gameList.getFromPlayer(author).ifPresent(game -> game.removePlayer(authorId)); lobbyList.findByPlayer(authorId).ifPresent(lobby -> lobby.unregisterPlayer(authorId, author.getEffectiveName())); channel.sendMessage("Successfully left the game/lobby").queue(null, null); } - private static Map, String> createStopConditions(GameList gameList, LobbyList lobbyList, String authorId) { + private static Map, String> createStopConditions(GameList gameList, LobbyList lobbyList, Member author) { Map, String> conditions = new HashMap<>(); conditions.put( - () -> !gameList.isPlaying(authorId) && lobbyList.findByPlayer(authorId).isEmpty(), + () -> !gameList.isPlaying(author) && lobbyList.findByPlayer(author.getId()).isEmpty(), "You are not registered in any game or lobby"); return conditions; } diff --git a/discord-impl/src/main/java/net/starype/quiz/discordimpl/command/NextRoundCommand.java b/discord-impl/src/main/java/net/starype/quiz/discordimpl/command/NextRoundCommand.java index b819e8a9..e0864679 100644 --- a/discord-impl/src/main/java/net/starype/quiz/discordimpl/command/NextRoundCommand.java +++ b/discord-impl/src/main/java/net/starype/quiz/discordimpl/command/NextRoundCommand.java @@ -1,5 +1,6 @@ package net.starype.quiz.discordimpl.command; +import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.entities.TextChannel; import net.starype.quiz.discordimpl.game.DiscordQuizGame; @@ -15,30 +16,31 @@ public class NextRoundCommand implements QuizCommand { public void execute(CommandContext context) { GameList gameList = context.getGameList(); - String playerId = context.getAuthor().getId(); + Member player = context.getAuthor(); TextChannel channel = context.getChannel(); Message message = context.getMessage(); - Map, String> conditions = createStopConditions(gameList, playerId); + Map, String> conditions = createStopConditions(gameList, player); if(StopConditions.shouldStop(conditions, channel, message)) { return; } - DiscordQuizGame game = gameList.getFromPlayer(playerId).get(); // value guaranteed to be present in our case + + DiscordQuizGame game = gameList.getFromPlayer(player).get(); // value guaranteed to be present in our case game.addLog(message.getId()); message.addReaction("\uD83D\uDC4D").queue(null, null); - game.addVote(playerId, null); + game.addVote(player.getId(), null); } - public static Map, String> createStopConditions(GameList gameList, String playerId) { + public static Map, String> createStopConditions(GameList gameList, Member player) { Map, String> conditions = new LinkedHashMap<>(); conditions.put( - () -> gameList.getFromPlayer(playerId).isEmpty(), + () -> gameList.getFromPlayer(player).isEmpty(), "You are not in any game"); conditions.put( - () -> !gameList.getFromPlayer(playerId).get().isWaitingForNextRound(), + () -> !gameList.getFromPlayer(player).get().isWaitingForNextRound(), "You can't vote to begin the next round, since the current one is not finished yet"); return conditions; diff --git a/discord-impl/src/main/java/net/starype/quiz/discordimpl/command/QuickStartCommand.java b/discord-impl/src/main/java/net/starype/quiz/discordimpl/command/QuickStartCommand.java new file mode 100644 index 00000000..d61afdc0 --- /dev/null +++ b/discord-impl/src/main/java/net/starype/quiz/discordimpl/command/QuickStartCommand.java @@ -0,0 +1,24 @@ +package net.starype.quiz.discordimpl.command; + +import net.starype.quiz.api.round.IndividualRoundFactory; +import net.starype.quiz.discordimpl.game.GameLobby; + +public class QuickStartCommand extends StartCommand { + + @Override + public String getName() { + return "quickstart"; + } + + @Override + public String getDescription() { + return "Start the game with default parameters"; + } + + @Override + protected void onPreStart(GameLobby lobby) { + lobby.resetRounds(); + RoundAddCommand.PartialRound defaultRound = q -> new IndividualRoundFactory().create(q, 1); + lobby.queueRound(defaultRound, 5); + } +} diff --git a/discord-impl/src/main/java/net/starype/quiz/discordimpl/command/StandardStartCommand.java b/discord-impl/src/main/java/net/starype/quiz/discordimpl/command/StandardStartCommand.java new file mode 100644 index 00000000..92afbfd2 --- /dev/null +++ b/discord-impl/src/main/java/net/starype/quiz/discordimpl/command/StandardStartCommand.java @@ -0,0 +1,22 @@ +package net.starype.quiz.discordimpl.command; + +import net.dv8tion.jda.api.interactions.commands.build.CommandData; +import net.starype.quiz.discordimpl.game.GameLobby; + +public class StandardStartCommand extends StartCommand { + + @Override + public String getName() { + return "start"; + } + + @Override + public String getDescription() { + return "Start a game. Must follow /create"; + } + + @Override + protected void onPreStart(GameLobby lobby) { + + } +} diff --git a/discord-impl/src/main/java/net/starype/quiz/discordimpl/command/StartGameCommand.java b/discord-impl/src/main/java/net/starype/quiz/discordimpl/command/StartCommand.java similarity index 87% rename from discord-impl/src/main/java/net/starype/quiz/discordimpl/command/StartGameCommand.java rename to discord-impl/src/main/java/net/starype/quiz/discordimpl/command/StartCommand.java index b9e0737f..75dbfeee 100644 --- a/discord-impl/src/main/java/net/starype/quiz/discordimpl/command/StartGameCommand.java +++ b/discord-impl/src/main/java/net/starype/quiz/discordimpl/command/StartCommand.java @@ -1,70 +1,65 @@ -package net.starype.quiz.discordimpl.command; - -import net.dv8tion.jda.api.entities.Member; -import net.dv8tion.jda.api.entities.Message; -import net.starype.quiz.discordimpl.game.GameLobby; -import net.starype.quiz.discordimpl.game.LobbyList; -import net.starype.quiz.discordimpl.util.CounterLimiter; - -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.function.Supplier; - -public class StartGameCommand implements QuizCommand { - - private final static CounterLimiter gameLimiter = new CounterLimiter(5); - - @Override - public String getName() { - return "start"; - } - - @Override - public String getDescription() { - return "Start a game. Must follow /create"; - } - - @Override - public void execute(CommandContext context) { - LobbyList lobbyList = context.getLobbyList(); - Member author = context.getAuthor(); - String authorId = author.getId(); - Message message = context.getMessage(); - - long uniqueId = lobbyList - .findByAuthor(authorId) - .map(name -> name.getName().hashCode()) - .orElse(0); - Map, String> conditions = createStopConditions(lobbyList, authorId, author.getEffectiveName(), uniqueId); - - if(StopConditions.shouldStop(conditions, context.getChannel(), message)) { - return; - } - - GameLobby lobby = lobbyList.findByAuthor(authorId).get(); - lobby.trackMessage(message.getId()); - if(lobby.start(context.getGameList(), () -> gameLimiter.unregister(uniqueId))) { - lobbyList.unregisterLobby(lobby); - } - } - - private static Map, String> createStopConditions(LobbyList lobbyList, String playerId, - String nickName, long uniqueId) { - Map, String> conditions = new LinkedHashMap<>(); - - conditions.put( - () -> lobbyList.findByPlayer(playerId).isEmpty(), - nickName + ", you are not in any lobby"); - - conditions.put( - () -> lobbyList.findByAuthor(playerId).isEmpty(), - nickName + ", only the owner of the lobby can start the game"); - - conditions.put( - () -> !gameLimiter.register(uniqueId), - "Error: Cannot create a new game as the maximum number of games has been reached"); - - - return conditions; - } -} +package net.starype.quiz.discordimpl.command; + +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.Message; +import net.starype.quiz.discordimpl.game.GameLobby; +import net.starype.quiz.discordimpl.game.LobbyList; +import net.starype.quiz.discordimpl.util.CounterLimiter; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.function.Supplier; + +public abstract class StartCommand implements QuizCommand { + + private final static CounterLimiter gameLimiter = new CounterLimiter(5); + + protected abstract void onPreStart(GameLobby lobby); + + @Override + public void execute(CommandContext context) { + LobbyList lobbyList = context.getLobbyList(); + Member author = context.getAuthor(); + String authorId = author.getId(); + Message message = context.getMessage(); + + long uniqueId = lobbyList + .findByAuthor(authorId) + .map(name -> name.getName().hashCode()) + .orElse(0); + Map, String> conditions = createStopConditions(lobbyList, authorId, author.getEffectiveName(), uniqueId); + + if(StopConditions.shouldStop(conditions, context.getChannel(), message)) { + return; + } + + GameLobby lobby = lobbyList.findByAuthor(authorId).get(); + lobby.trackMessage(message.getId()); + + onPreStart(lobby); + + if(lobby.start(context.getGameList(), () -> gameLimiter.unregister(uniqueId))) { + lobbyList.unregisterLobby(lobby); + } + } + + private static Map, String> createStopConditions(LobbyList lobbyList, String playerId, + String nickName, long uniqueId) { + Map, String> conditions = new LinkedHashMap<>(); + + conditions.put( + () -> lobbyList.findByPlayer(playerId).isEmpty(), + nickName + ", you are not in any lobby"); + + conditions.put( + () -> lobbyList.findByAuthor(playerId).isEmpty(), + nickName + ", only the owner of the lobby can start the game"); + + conditions.put( + () -> !gameLimiter.register(uniqueId), + "Error: Cannot create a new game as the maximum number of games has been reached"); + + + return conditions; + } +} \ No newline at end of file diff --git a/discord-impl/src/main/java/net/starype/quiz/discordimpl/command/StartStandardCommand.java b/discord-impl/src/main/java/net/starype/quiz/discordimpl/command/StartStandardCommand.java new file mode 100644 index 00000000..2acac255 --- /dev/null +++ b/discord-impl/src/main/java/net/starype/quiz/discordimpl/command/StartStandardCommand.java @@ -0,0 +1,21 @@ +package net.starype.quiz.discordimpl.command; + +import net.starype.quiz.discordimpl.game.GameLobby; + +public class StartStandardCommand extends StartCommand { + + @Override + public String getName() { + return "start"; + } + + @Override + public String getDescription() { + return "Start a game. Must follow /create"; + } + + @Override + protected void onPreStart(GameLobby lobby) { + + } +} diff --git a/discord-impl/src/main/java/net/starype/quiz/discordimpl/command/SubmitCommand.java b/discord-impl/src/main/java/net/starype/quiz/discordimpl/command/SubmitCommand.java index 99c559f9..72787acd 100644 --- a/discord-impl/src/main/java/net/starype/quiz/discordimpl/command/SubmitCommand.java +++ b/discord-impl/src/main/java/net/starype/quiz/discordimpl/command/SubmitCommand.java @@ -1,6 +1,8 @@ package net.starype.quiz.discordimpl.command; +import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.TextChannel; import net.starype.quiz.api.game.QuizGame; import net.starype.quiz.discordimpl.game.GameList; @@ -24,19 +26,19 @@ public String getDescription() { @Override public void execute(CommandContext context) { - String authorId = context.getAuthor().getId(); + Member author = context.getAuthor(); GameList gameList = context.getGameList(); String[] args = reconstructArgs(context.getArgs()); - - Map, String> conditions = createStopConditions(authorId, gameList, args); + TextChannel channel = context.getChannel(); + Map, String> conditions = createStopConditions(author, channel, gameList, args); Message message = context.getMessage(); - if(StopConditions.shouldStop(conditions, context.getChannel(), message)) { + if(StopConditions.shouldStop(conditions, channel, message)) { return; } - QuizGame game = gameList.getFromPlayer(authorId).get(); - game.sendInput(authorId, args[1].substring(2, args[1].length()-2)); + QuizGame game = gameList.findGameFor(author, channel, true).get(); + game.sendInput(author.getId(), args[1].substring(2, args[1].length()-2)); message.delete().queue(null, null); } @@ -51,10 +53,10 @@ private static String[] reconstructArgs(String[] args) { return newArgs; } - private static Map, String> createStopConditions(String authorId, GameList gameList, String[] args) { + private static Map, String> createStopConditions(Member author, TextChannel channel, GameList gameList, String[] args) { Map, String> conditions = new LinkedHashMap<>(); conditions.put( - () -> !gameList.isPlaying(authorId), + () -> !gameList.canPlay(author, channel), "You can't submit an answer if you're not in a game"); conditions.put( () -> args.length != 2 || !args[1].matches("\\|\\|.*?\\|\\|"), diff --git a/discord-impl/src/main/java/net/starype/quiz/discordimpl/command/ZipQuestionSetCommand.java b/discord-impl/src/main/java/net/starype/quiz/discordimpl/command/ZipQuestionSetCommand.java index b633dfd4..b2fe5dcd 100644 --- a/discord-impl/src/main/java/net/starype/quiz/discordimpl/command/ZipQuestionSetCommand.java +++ b/discord-impl/src/main/java/net/starype/quiz/discordimpl/command/ZipQuestionSetCommand.java @@ -40,7 +40,7 @@ public void execute(CommandContext context) { } String url = findUrl(message, args); - Collection updaters = InputUtils.loadEntryUpdaters(url, channel); + Collection updaters = InputUtils.loadEntryUpdatersFromUrl(url, channel); SerializedIO serializedIO = new ByteSerializedIO(new byte[0], new AtomicReference<>()); GameLobby lobby = lobbyList.findByAuthor(authorId).get(); diff --git a/discord-impl/src/main/java/net/starype/quiz/discordimpl/command/event/WeeklyQuizCommand.java b/discord-impl/src/main/java/net/starype/quiz/discordimpl/command/event/WeeklyQuizCommand.java new file mode 100644 index 00000000..c7943032 --- /dev/null +++ b/discord-impl/src/main/java/net/starype/quiz/discordimpl/command/event/WeeklyQuizCommand.java @@ -0,0 +1,142 @@ +package net.starype.quiz.discordimpl.command.event; + +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.TextChannel; +import net.starype.quiz.api.database.*; +import net.starype.quiz.api.question.QuestionDifficulty; +import net.starype.quiz.api.round.QuizRound; +import net.starype.quiz.discordimpl.command.CommandContext; +import net.starype.quiz.discordimpl.command.QuizCommand; +import net.starype.quiz.discordimpl.command.StopConditions; +import net.starype.quiz.discordimpl.game.FlexiblePlayersRoundFactory; +import net.starype.quiz.discordimpl.game.GameList; +import net.starype.quiz.discordimpl.game.LobbyList; +import net.starype.quiz.discordimpl.util.InputUtils; + +import java.time.LocalDate; +import java.time.Month; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +public class WeeklyQuizCommand implements QuizCommand { + + private static final String PATH = "discord-impl/src/main/resources/event/Week"; + private static final LocalDate START_TIME = LocalDate.of(2022, Month.FEBRUARY, 21); + + @Override + public String getName() { + return "weekly"; + } + + @Override + public String getDescription() { + return "Weekly quizzes on CS108 content"; + } + + @Override + public void execute(CommandContext context) { + + GameList gameList = context.getGameList(); + LobbyList lobbyList = context.getLobbyList(); + Member author = context.getAuthor(); + TextChannel channel = context.getChannel(); + Message message = context.getMessage(); + String[] args = context.getArgs(); + + Map, String> conditions = createStopConditions( + gameList, lobbyList, + author, author.getEffectiveName(), + args); + + if(StopConditions.shouldStop(conditions, channel, message)) { + return; + } + + int week = Integer.parseInt(args[1]); + Queue questions = findQuestions(week, channel); + + if(questions.isEmpty()) { + return; + } + + gameList.startNewGame( + new ArrayList<>(Collections.singleton(author.getId())), + questions, channel, + author.getId(), + () -> {}, + channel.getGuild().getId(), + false); + } + + private Queue findQuestions(int week, TextChannel channel) { + + if(!isAvailable(week, channel)) { + return new LinkedList<>(); + } + + String path = PATH + week + ".zip"; + Collection entries = InputUtils.loadEntryUpdatersFromLocalPath(path, channel); + SerializedIO serializedIO = new ByteSerializedIO(new byte[0], new AtomicReference<>()); + QuestionDatabase db = new QuestionDatabase(entries, serializedIO, false); + try { + db.sync(); + } catch (Exception ignored) { + return new LinkedList<>(); + } + + return db.listQuery(QuestionQueries.ALL) + .stream() + .sorted(Comparator.comparingInt(a -> a.getDifficulty().ordinal())) + .map(q -> new FlexiblePlayersRoundFactory().create(q, mapDifficulty(q.getDifficulty()), channel)) + .collect(Collectors.toCollection(LinkedList::new)); + } + + private boolean isAvailable(int week, TextChannel channel) { + LocalDate now = LocalDate.now(ZoneId.of("UTC+1")); + LocalDate required = START_TIME.plusWeeks(week - 1); + if(now.isBefore(required)) { + String format = required + .format(DateTimeFormatter.ofPattern("MMMM d", Locale.ENGLISH)); + channel.sendMessage("Access denied <:pandadiablotin:843123536825548810>\n" + + "This quiz will be available on `" + format + "` . Stay tuned!").queue(null, null); + return false; + } + return true; + } + + private static double mapDifficulty(QuestionDifficulty diff) { + switch (diff) { + case EASY: + case NORMAL: + return 1; + case HARD: + case INSANE: + default: + return 1.5; + } + } + + private Map, String> createStopConditions( + GameList gameList, LobbyList lobbyList, Member author, String nickName, String[] args) { + + Map, String> conditions = new LinkedHashMap<>(); + conditions.put( + () -> args.length != 2 || !args[1].matches("\\b([1-9]|1[0-4])\\b"), + "Invalid syntax: please use ?weekly "); + + conditions.put( + () -> lobbyList.findByPlayer(author.getId()).isPresent(), + nickName + ", you are already in a lobby"); + + conditions.put( + () -> gameList.isPlaying(author), + nickName + ", you are already playing a game"); + + return conditions; + } +} diff --git a/discord-impl/src/main/java/net/starype/quiz/discordimpl/game/DiscordGameServer.java b/discord-impl/src/main/java/net/starype/quiz/discordimpl/game/DiscordGameServer.java index 26a04d6e..d34b0297 100644 --- a/discord-impl/src/main/java/net/starype/quiz/discordimpl/game/DiscordGameServer.java +++ b/discord-impl/src/main/java/net/starype/quiz/discordimpl/game/DiscordGameServer.java @@ -21,8 +21,8 @@ public class DiscordGameServer extends DiscordLogContainer implements GameServer { - private TextChannel channel; - private Consumer endAction; + private final TextChannel channel; + private final Consumer endAction; public DiscordGameServer(TextChannel channel, Consumer endAction) { super(channel); @@ -36,6 +36,8 @@ public void onRoundEnded(GameRoundReport report, DiscordQuizGame game) { sendLeaderboard(report.orderedStandings()); if(game.isOutOfRounds()) { sendAsText("This was the last round. Use ?next to vote for displaying the game results"); + } else { + sendAsText("Use ?next to go to the next round"); } } diff --git a/discord-impl/src/main/java/net/starype/quiz/discordimpl/game/DiscordLogContainer.java b/discord-impl/src/main/java/net/starype/quiz/discordimpl/game/DiscordLogContainer.java index adf87694..24b89cfb 100644 --- a/discord-impl/src/main/java/net/starype/quiz/discordimpl/game/DiscordLogContainer.java +++ b/discord-impl/src/main/java/net/starype/quiz/discordimpl/game/DiscordLogContainer.java @@ -31,6 +31,6 @@ public void deleteMessages() { private void deleteLog(String id) { channel.retrieveMessageById(id) .flatMap(Message::delete) - .queue(message -> logs.remove(id), null); + .queue(message -> logs.remove(id), e -> {}); } } diff --git a/discord-impl/src/main/java/net/starype/quiz/discordimpl/game/DiscordQuizGame.java b/discord-impl/src/main/java/net/starype/quiz/discordimpl/game/DiscordQuizGame.java index 6c033ed6..36664a5a 100644 --- a/discord-impl/src/main/java/net/starype/quiz/discordimpl/game/DiscordQuizGame.java +++ b/discord-impl/src/main/java/net/starype/quiz/discordimpl/game/DiscordQuizGame.java @@ -1,32 +1,42 @@ package net.starype.quiz.discordimpl.game; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.TextChannel; +import net.dv8tion.jda.api.entities.User; import net.starype.quiz.api.game.SimpleGame; -import net.starype.quiz.api.player.Player; import net.starype.quiz.api.round.QuizRound; import net.starype.quiz.api.server.ServerGate; +import net.starype.quiz.discordimpl.user.DiscordPlayer; -import java.util.Collection; -import java.util.HashSet; -import java.util.Queue; -import java.util.Set; +import java.util.*; +import java.util.function.Predicate; +import java.util.stream.Collectors; public class DiscordQuizGame extends SimpleGame { - private Set votesForNext; - private String authorId; - private LogContainer container; + private final Set votesForNext; + private final String authorId; + private final LogContainer container; + private final TextChannel channel; + private final Collection players; + private final Guild guild; + private final boolean fixedPlayerList; public DiscordQuizGame( Queue rounds, - Collection> players, + Collection players, ServerGate gate, String authorId, - LogContainer container) { + LogContainer container, Guild guild, TextChannel channel, boolean fixedPlayerList) { super(rounds, players); + this.players = players; this.authorId = authorId; this.container = container; + this.channel = channel; this.votesForNext = new HashSet<>(); + this.guild = guild; + this.fixedPlayerList = fixedPlayerList; this.setGate(gate.withGame(this)); } @@ -37,8 +47,11 @@ public boolean nextRound() { } public boolean addVote(String playerId, Runnable ifReady) { + if(!isWaitingForNextRound()) { + return false; + } votesForNext.add(playerId); - if(votesForNext.size() < getPlayers().size()) { + if(votesForNext.size() < players.size()) { return false; } votesForNext.clear(); @@ -49,6 +62,12 @@ public boolean addVote(String playerId, Runnable ifReady) { return true; } + @Override + public void removePlayer(Object playerId) { + addVote((String) playerId, null); + super.removePlayer(playerId); + } + public boolean isAuthor(String playerId) { return playerId.equals(authorId); } @@ -60,4 +79,56 @@ public void deleteLogs() { public void addLog(String id) { container.trackMessage(id); } + + public List waitingFor() { + return retrievePlayers(p -> getCurrentRound().getPlayerEligibility().isEligible(p)); + } + + public List haveAnswered() { + return retrievePlayers(p -> !getCurrentRound().getPlayerEligibility().isEligible(p)); + } + + public List allPlayerNames() { + return retrievePlayers(p -> true); + } + + public List haveNotVoted() { + return retrievePlayers(p -> !votesForNext.contains(p.getId())); + } + + public boolean hasRoundStarted() { + return getCurrentRound().hasStarted(); + } + + private List retrievePlayers(Predicate filter) { + return players + .stream() + .filter(filter) + .map(p -> guild.getJDA().retrieveUserById(p.getId()).complete()) + .map(User::getName) + .collect(Collectors.toList()); + } + + public void checkEndOfRound() { + getCurrentRound().checkEndOfRound(); + } + + public void insertNewPlayer(DiscordPlayer player) { + if(fixedPlayerList) { + throw new IllegalStateException("Cannot insert player in a fixed player list setting"); + } + players.add(player); + } + + public boolean supportsNonFixedPlayerList() { + return !fixedPlayerList; + } + + public String assignedGuildId() { + return guild.getId(); + } + + public String assignedChannelId() { + return channel.getId(); + } } diff --git a/discord-impl/src/main/java/net/starype/quiz/discordimpl/game/DiscordQuizTimer.java b/discord-impl/src/main/java/net/starype/quiz/discordimpl/game/DiscordQuizTimer.java new file mode 100644 index 00000000..1b723859 --- /dev/null +++ b/discord-impl/src/main/java/net/starype/quiz/discordimpl/game/DiscordQuizTimer.java @@ -0,0 +1,32 @@ +package net.starype.quiz.discordimpl.game; + +import net.dv8tion.jda.api.entities.TextChannel; +import net.starype.quiz.api.game.QuizTimer; +import net.starype.quiz.discordimpl.util.MessageUtils; + +import java.util.concurrent.TimeUnit; + +public class DiscordQuizTimer extends QuizTimer { + + private final TextChannel channel; + private boolean sent = false; + + public DiscordQuizTimer(TimeUnit unit, long time, TextChannel channel) { + this(unit, time, time, channel); + } + + public DiscordQuizTimer(TimeUnit unit, long time, long shortenedTime, TextChannel channel) { + super(unit, time, shortenedTime); + this.channel = channel; + } + + @Override + public void update(long deltaMillis) { + double left = millisLeft() / 1000.0; + if(left <= 30 && !sent) { + MessageUtils.createTemporaryMessage("> :warning: 20 seconds left!", channel); + sent = true; + } + super.update(deltaMillis); + } +} diff --git a/discord-impl/src/main/java/net/starype/quiz/discordimpl/game/FlexiblePlayersRoundFactory.java b/discord-impl/src/main/java/net/starype/quiz/discordimpl/game/FlexiblePlayersRoundFactory.java new file mode 100644 index 00000000..95b08a50 --- /dev/null +++ b/discord-impl/src/main/java/net/starype/quiz/discordimpl/game/FlexiblePlayersRoundFactory.java @@ -0,0 +1,61 @@ +package net.starype.quiz.discordimpl.game; + +import net.dv8tion.jda.api.entities.TextChannel; +import net.starype.quiz.api.event.GameUpdatable; +import net.starype.quiz.api.game.*; +import net.starype.quiz.api.player.IDHolder; +import net.starype.quiz.api.question.Question; +import net.starype.quiz.api.round.*; + +import java.util.Collection; +import java.util.concurrent.TimeUnit; + +public class FlexiblePlayersRoundFactory { + + public QuizRound create(Question question, double maxToAward, TextChannel channel) { + + IsGuessValid isGuessValid = new IsGuessValid(); + GuessCounter counter = new GuessCounter(1); + RoundState roundState = new RoundState(counter); + + SwitchPredicate timeOutEnding = new SwitchPredicate(false, roundState); + QuizTimer quizTimer = new DiscordQuizTimer(TimeUnit.SECONDS, 480, 20, channel); + GameUpdatable shortener = new TimeShortener(counter); + + GuessReceivedAction consumer = + new InvalidateCurrentPlayerCorrectness().withCondition(isGuessValid.negate()) + .followedBy(new MakePlayerEligible().withCondition(isGuessValid.negate())) + .followedBy(new UpdateLeaderboard().withCondition(isGuessValid)) + .followedBy(new IncrementPlayerGuess().withCondition(isGuessValid)); + + StandardRound round = new StandardRound.Builder() + .withGuessReceivedAction(consumer) + .withGiveUpReceivedConsumer(new AddCorrectnessIfNew().followedBy(new ConsumePlayerGuess())) + .withRoundState(roundState) + .withQuestion(question) + .withEndingCondition(timeOutEnding) + .withPlayerEligibility(new ModifiedMaxGuess(counter)) + .withScoreDistribution(new OneTryDistribution(maxToAward, roundState.getLeaderboard())) + .addEvent(quizTimer) + .addEvent(shortener) + .build(); + + shortener.addEventListener(quizTimer); + shortener.addEventListener(round::checkEndOfRound); + quizTimer.addEventListener(timeOutEnding); + quizTimer.addEventListener(round::checkEndOfRound); + return round; + } + + private static class ModifiedMaxGuess extends MaxGuess { + + public ModifiedMaxGuess(GuessCounter counter) { + super(counter); + } + + @Override + public boolean existsEligible(Collection> players) { + return true; + } + } +} diff --git a/discord-impl/src/main/java/net/starype/quiz/discordimpl/game/GameList.java b/discord-impl/src/main/java/net/starype/quiz/discordimpl/game/GameList.java index 1271215d..fa893bfc 100644 --- a/discord-impl/src/main/java/net/starype/quiz/discordimpl/game/GameList.java +++ b/discord-impl/src/main/java/net/starype/quiz/discordimpl/game/GameList.java @@ -2,6 +2,7 @@ import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.MessageChannel; import net.dv8tion.jda.api.entities.TextChannel; import net.starype.quiz.api.round.QuizRound; import net.starype.quiz.api.server.ServerGate; @@ -24,17 +25,19 @@ public GameList() { } public void startNewGame(Collection playersId, Queue rounds, TextChannel channel, String authorId, - Runnable onGameEndedCallback) { + Runnable onGameEndedCallback, String guildId, boolean fixedPlayerList) { Consumer naturalEndAction = game -> { this.stopGame(game); onGameEndedCallback.run(); }; + Collection gamePlayers = asGamePlayers(playersId, channel); DiscordGameServer server = new DiscordGameServer(channel, naturalEndAction); ServerGate gate = server.createGate(); - DiscordQuizGame game = new DiscordQuizGame(rounds, gamePlayers, gate, authorId, server); + Guild guild = fromId(channel, guildId); + DiscordQuizGame game = new DiscordQuizGame(rounds, gamePlayers, gate, authorId, server, guild, channel, fixedPlayerList); Runnable forcedEndAction = () -> { stopGame(game, true, channel); @@ -42,7 +45,7 @@ public void startNewGame(Collection playersId, Queue future = autoUpdate.scheduleAtFixedRate(game::update, 0, 250, TimeUnit.MILLISECONDS); @@ -50,6 +53,10 @@ public void startNewGame(Collection playersId, Queue asGamePlayers(Collection playersId, TextChannel channel) { Guild guild = channel.getGuild(); return playersId @@ -59,21 +66,43 @@ private Collection asGamePlayers(Collection pla .collect(Collectors.toList()); } - public boolean isPlaying(String playerId) { + public boolean isPlaying(Member player) { return ongoingGames .keySet() .stream() - .anyMatch((game) -> game.containsPlayerId(playerId)); + .anyMatch((game) -> game.containsPlayerId(player.getId())); } - public Optional getFromPlayer(String playerId) { + public boolean canPlay(Member player, TextChannel channel) { + return findGameFor(player, channel, false).isPresent(); + } + + public Optional getFromPlayer(Member player) { return ongoingGames .keySet() .stream() - .filter(game -> game.containsPlayerId(playerId)) + .filter(game -> game.containsPlayerId(player.getId())) .findAny(); } + public Optional findGameFor(Member player, TextChannel channel, boolean addIfFound) { + return getFromPlayer(player) + .or(() -> findNewGameFor(player, channel, addIfFound)); + } + + private Optional findNewGameFor(Member player, TextChannel channel, boolean addIfFound) { + Optional potentialGame = ongoingGames + .keySet() + .stream() + .filter(DiscordQuizGame::supportsNonFixedPlayerList) + .filter(game -> game.assignedChannelId().equals(channel.getId())) + .findAny(); + if(addIfFound) { + potentialGame.ifPresent(game -> game.insertNewPlayer(asPlayer(player))); + } + return potentialGame; + } + public void stopGame(DiscordQuizGame game) { stopGame(game, false, null); } diff --git a/discord-impl/src/main/java/net/starype/quiz/discordimpl/game/GameLobby.java b/discord-impl/src/main/java/net/starype/quiz/discordimpl/game/GameLobby.java index b4a7a14b..292b8c3f 100644 --- a/discord-impl/src/main/java/net/starype/quiz/discordimpl/game/GameLobby.java +++ b/discord-impl/src/main/java/net/starype/quiz/discordimpl/game/GameLobby.java @@ -2,6 +2,7 @@ import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.entities.TextChannel; +import net.dv8tion.jda.api.entities.User; import net.dv8tion.jda.api.requests.RestAction; import net.starype.quiz.api.database.QuestionQueries; import net.starype.quiz.api.database.QuestionQuery; @@ -28,6 +29,7 @@ public class GameLobby extends DiscordLogContainer { private final Runnable destructLobbyCallback; + private final String guildId; private final String name; private final TextChannel channel; private final Set playersId; @@ -44,6 +46,7 @@ public GameLobby(TextChannel channel, String name, Runnable destructLobbyCallbac this.channel = channel; this.name = name; this.destructLobbyCallback = destructLobbyCallback; + this.guildId = channel.getGuild().getId(); this.partialRounds = new LinkedList<>(); this.playersId = new HashSet<>(); } @@ -110,10 +113,9 @@ public boolean start(GameList gameList, Runnable onGameEndedCallback) { .map(partial -> partial.apply(questions.poll())) .collect(Collectors.toCollection(LinkedList::new)); - deleteMessages(); destructLobbyCallback.run(); - gameList.startNewGame(playersId, rounds, channel, authorId, onGameEndedCallback); + gameList.startNewGame(playersId, rounds, channel, authorId, onGameEndedCallback, guildId, true); return true; } @@ -243,4 +245,29 @@ public void queueRound(PartialRound round) { partialRounds.add(round); } } + + public List retrieveNames() { + return playersId + .stream() + .map(id -> channel.getJDA().retrieveUserById(id).complete()) + .map(User::getName) + .collect(Collectors.toList()); + } + + public int questionCount() { + if(queryObject == null) { + return 0; + } + return queryObject.listQuery(query == null ? QuestionQueries.ALL : query).size(); + } + + public void resetRounds() { + partialRounds.clear(); + } + + public void queueRound(PartialRound round, int repeat) { + for(int i = 0; i < repeat; i++) { + queueRound(round); + } + } } diff --git a/discord-impl/src/main/java/net/starype/quiz/discordimpl/game/LobbyList.java b/discord-impl/src/main/java/net/starype/quiz/discordimpl/game/LobbyList.java index c6d7cd88..9403709b 100644 --- a/discord-impl/src/main/java/net/starype/quiz/discordimpl/game/LobbyList.java +++ b/discord-impl/src/main/java/net/starype/quiz/discordimpl/game/LobbyList.java @@ -10,8 +10,8 @@ public class LobbyList { - private Set lobbies; - private ReactionInputListener reactionListener; + private final Set lobbies; + private final ReactionInputListener reactionListener; private int nextId; public LobbyList(ReactionInputListener reactionListener) { diff --git a/discord-impl/src/main/java/net/starype/quiz/discordimpl/game/TimeShortener.java b/discord-impl/src/main/java/net/starype/quiz/discordimpl/game/TimeShortener.java new file mode 100644 index 00000000..8c28b444 --- /dev/null +++ b/discord-impl/src/main/java/net/starype/quiz/discordimpl/game/TimeShortener.java @@ -0,0 +1,35 @@ +package net.starype.quiz.discordimpl.game; + +import net.starype.quiz.api.event.GameUpdatable; +import net.starype.quiz.api.event.UpdatableHandler; +import net.starype.quiz.api.game.GuessCounter; +import net.starype.quiz.api.game.QuizTimer; + +public class TimeShortener extends GameUpdatable { + + private final GuessCounter counter; + private UpdatableHandler updatableHandler; + + public TimeShortener(GuessCounter counter) { + this.counter = counter; + } + + @Override + public void start(UpdatableHandler updatableHandler) { + this.updatableHandler = updatableHandler; + updatableHandler.registerEvent(this); + } + + @Override + public void shutDown() { + updatableHandler.unregisterEvent(this); + } + + @Override + public void update(long deltaMillis) { + if(!counter.isEmpty()) { + notifyListeners(); + shutDown(); + } + } +} diff --git a/discord-impl/src/main/java/net/starype/quiz/discordimpl/input/MessageInputListener.java b/discord-impl/src/main/java/net/starype/quiz/discordimpl/input/MessageInputListener.java index f16304bd..a44d2495 100644 --- a/discord-impl/src/main/java/net/starype/quiz/discordimpl/input/MessageInputListener.java +++ b/discord-impl/src/main/java/net/starype/quiz/discordimpl/input/MessageInputListener.java @@ -8,6 +8,7 @@ import net.dv8tion.jda.api.hooks.ListenerAdapter; import net.starype.quiz.discordimpl.command.*; import net.starype.quiz.discordimpl.command.CommandContext.MessageContext; +import net.starype.quiz.discordimpl.command.event.WeeklyQuizCommand; import net.starype.quiz.discordimpl.game.GameList; import net.starype.quiz.discordimpl.game.LobbyList; import org.jetbrains.annotations.NotNull; @@ -76,7 +77,8 @@ private Optional findByName(String name) { private Collection initCommands() { return new ArrayList<>(Arrays.asList( new CreateLobbyCommand(), - new StartGameCommand(), + new StandardStartCommand(), + new QuickStartCommand(), new SubmitCommand(), new LeaveCommand(), new NextRoundCommand(), @@ -87,7 +89,10 @@ private Collection initCommands() { new ZipQuestionSetCommand(), new QueryAddCommand(), new ClearQueryCommand(), - new RoundAddCommand() + new RoundAddCommand(), + new InfoCommand(), + new KickPlayerCommand(), + new WeeklyQuizCommand() )); } } diff --git a/discord-impl/src/main/java/net/starype/quiz/discordimpl/util/CounterLimiter.java b/discord-impl/src/main/java/net/starype/quiz/discordimpl/util/CounterLimiter.java index d041a671..8394e438 100644 --- a/discord-impl/src/main/java/net/starype/quiz/discordimpl/util/CounterLimiter.java +++ b/discord-impl/src/main/java/net/starype/quiz/discordimpl/util/CounterLimiter.java @@ -26,7 +26,8 @@ public synchronized boolean register(long uniqueId) { public synchronized void unregister(long uniqueId) { if(!instances.contains(uniqueId)) { - throw new RuntimeException("Cannot unregister a non-registered id"); + //throw new RuntimeException("Cannot unregister a non-registered id"); + return; } instances.removeIf(i -> i.equals(Thread.currentThread().getId())); } diff --git a/discord-impl/src/main/java/net/starype/quiz/discordimpl/util/ImageUtils.java b/discord-impl/src/main/java/net/starype/quiz/discordimpl/util/ImageUtils.java index 342711bd..1d52d7db 100644 --- a/discord-impl/src/main/java/net/starype/quiz/discordimpl/util/ImageUtils.java +++ b/discord-impl/src/main/java/net/starype/quiz/discordimpl/util/ImageUtils.java @@ -8,16 +8,15 @@ import javax.imageio.ImageIO; import java.awt.*; import java.awt.image.BufferedImage; -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.InputStream; +import java.io.*; import java.net.URL; import java.util.List; import java.util.Random; public class ImageUtils { + private static final String FONT_PATH = "discord-impl/src/main/resources/bowlby-one-sc/BowlbyOneSC-Regular.ttf"; + public static InputStream toInputStream(BufferedImage image) { ByteArrayOutputStream os = new ByteArrayOutputStream(); @@ -50,8 +49,15 @@ private static void initBackground(int width, int height, Graphics2D graphics) { graphics.clearRect(0, 0, width, height); graphics.setColor(Color.WHITE); graphics.addRenderingHints(new RenderingHints(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON)); - graphics.setFont(new Font("Bowlby One SC", Font.PLAIN, height / 9)); // Bowlby One SC - graphics.drawString("Results", width / 2 - 210, height / 7); + Font font; + try { + font = Font.createFont(Font.TRUETYPE_FONT, new FileInputStream(FONT_PATH)); + font = font.deriveFont(130.0f); + } catch (FontFormatException | IOException e) { + font = new Font("Bowlby One SC", Font.PLAIN, height / 9); + } + graphics.setFont(font); // Bowlby One SC + graphics.drawString("Results", width / 2 - 280, height / 7); } private static void initLeaderboard(int width, int height, Graphics2D graphics, Guild guild, List standings) { diff --git a/discord-impl/src/main/java/net/starype/quiz/discordimpl/util/InputUtils.java b/discord-impl/src/main/java/net/starype/quiz/discordimpl/util/InputUtils.java index 98a45bab..fbe16f26 100644 --- a/discord-impl/src/main/java/net/starype/quiz/discordimpl/util/InputUtils.java +++ b/discord-impl/src/main/java/net/starype/quiz/discordimpl/util/InputUtils.java @@ -3,12 +3,12 @@ import net.dv8tion.jda.api.entities.TextChannel; import net.starype.quiz.api.database.ByteEntryUpdater; import net.starype.quiz.api.database.EntryUpdater; +import scala.collection.LinearSeq; import java.io.*; import java.net.URL; -import java.util.Collection; -import java.util.HashSet; -import java.util.Set; +import java.nio.charset.StandardCharsets; +import java.util.*; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; @@ -18,33 +18,51 @@ public class InputUtils { private static final CounterLimiter downloadingLimiter = new CounterLimiter(5); - public static Collection loadEntryUpdaters(String urlName, TextChannel channel) { - Set updaters = new HashSet<>(); + public static Collection loadEntryUpdatersFromUrl(String urlName, TextChannel channel) { + + Collection updaters = new HashSet<>(); try { URL url = new URL(urlName); if(!downloadingLimiter.register(urlName.hashCode())) { channel.sendMessage("Error: The limit of downloading zip has been reached").queue(); - return updaters; } + updaters = loadEntryUpdaters(url.openStream(), channel); + + } catch (IOException ignored) { + channel.sendMessage("Error: couldn't load the provided zip archive").queue(null, null); + } - ZipInputStream zipStream = new ZipInputStream(url.openStream()); + // Release the instance of the current thread (as we finished the download process) + downloadingLimiter.unregister(urlName.hashCode()); + return updaters; + } + public static Collection loadEntryUpdatersFromLocalPath(String path, TextChannel channel) { + try { + return loadEntryUpdaters(new FileInputStream(path), channel); + } catch (FileNotFoundException e) { + channel.sendMessage("Error: local file not found").queue(null, null); + } + return Collections.emptySet(); + } + + public static Collection loadEntryUpdaters(InputStream input, TextChannel channel) { + List updaters = new ArrayList<>(); + try { + ZipInputStream zipStream = new ZipInputStream(input, StandardCharsets.UTF_8); ZipEntry current; while ((current = zipStream.getNextEntry()) != null) { readEntry(zipStream, current, updaters); } - } catch (IOException ignored) { + } catch (IOException e) { channel.sendMessage("Error: couldn't load the provided zip archive").queue(null, null); } - - // Release the instance of the current thread (as we finished the download process) - downloadingLimiter.unregister(urlName.hashCode()); return updaters; } - private static void readEntry(ZipInputStream zipStream, ZipEntry current, Set updaters) throws IOException { + private static void readEntry(ZipInputStream zipStream, ZipEntry current, List updaters) throws IOException { long size = current.getSize(); diff --git a/discord-impl/src/main/java/net/starype/quiz/discordimpl/util/MessageUtils.java b/discord-impl/src/main/java/net/starype/quiz/discordimpl/util/MessageUtils.java index 7a3cc692..f592ff7c 100644 --- a/discord-impl/src/main/java/net/starype/quiz/discordimpl/util/MessageUtils.java +++ b/discord-impl/src/main/java/net/starype/quiz/discordimpl/util/MessageUtils.java @@ -1,19 +1,17 @@ package net.starype.quiz.discordimpl.util; import net.dv8tion.jda.api.entities.Message; + +import net.dv8tion.jda.api.entities.MessageEmbed; import net.dv8tion.jda.api.entities.TextChannel; import net.dv8tion.jda.api.requests.RestAction; import net.dv8tion.jda.api.requests.restaction.MessageAction; import net.starype.quiz.discordimpl.game.LogContainer; import java.io.InputStream; -import java.util.Objects; -import java.util.Optional; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; -import java.util.function.Function; -import java.util.function.Supplier; public class MessageUtils { @@ -23,6 +21,12 @@ public static void sendAndTrack(String text, TextChannel channel, LogContainer c .queue(container::trackMessage, null); } + public static void sendAndTrack(MessageEmbed embed, TextChannel channel, LogContainer container) { + channel.sendMessageEmbeds(embed) + .map(Message::getId) + .queue(container::trackMessage, null); + } + public static void sendAndTrack(InputStream image, String name, TextChannel channel, LogContainer container) { channel .sendFile(image, name) diff --git a/discord-impl/src/main/resources/bowlby-one-sc/BowlbyOneSC-Regular.ttf b/discord-impl/src/main/resources/bowlby-one-sc/BowlbyOneSC-Regular.ttf new file mode 100644 index 00000000..2135aec5 Binary files /dev/null and b/discord-impl/src/main/resources/bowlby-one-sc/BowlbyOneSC-Regular.ttf differ diff --git a/discord-impl/src/main/resources/bowlby-one-sc/OFL.txt b/discord-impl/src/main/resources/bowlby-one-sc/OFL.txt new file mode 100644 index 00000000..1572d364 --- /dev/null +++ b/discord-impl/src/main/resources/bowlby-one-sc/OFL.txt @@ -0,0 +1,93 @@ +Copyright (c) 2011 by vernon adams (vern@newtypography.co.uk), +with Reserved Font Names "Bowlby" "Bowlby One" and "Bowlby One SC" +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/discord-impl/src/main/resources/event/Week1.zip b/discord-impl/src/main/resources/event/Week1.zip new file mode 100644 index 00000000..75414b67 Binary files /dev/null and b/discord-impl/src/main/resources/event/Week1.zip differ diff --git a/discord-impl/src/main/resources/event/Week2.zip b/discord-impl/src/main/resources/event/Week2.zip new file mode 100644 index 00000000..91b75a61 Binary files /dev/null and b/discord-impl/src/main/resources/event/Week2.zip differ diff --git a/discord-impl/src/main/resources/event/Week3.zip b/discord-impl/src/main/resources/event/Week3.zip new file mode 100644 index 00000000..1779ef3c Binary files /dev/null and b/discord-impl/src/main/resources/event/Week3.zip differ diff --git a/discord-impl/src/main/resources/event/Week4.zip b/discord-impl/src/main/resources/event/Week4.zip new file mode 100644 index 00000000..650bcf44 Binary files /dev/null and b/discord-impl/src/main/resources/event/Week4.zip differ diff --git a/discord-impl/src/main/resources/event/Week5.zip b/discord-impl/src/main/resources/event/Week5.zip new file mode 100644 index 00000000..11ab62aa Binary files /dev/null and b/discord-impl/src/main/resources/event/Week5.zip differ