diff --git a/src/main/java/pw/chew/chewbotcca/util/DatabaseHelper.java b/src/main/java/pw/chew/chewbotcca/util/DatabaseHelper.java index 3f6c1bb..6f43ba5 100644 --- a/src/main/java/pw/chew/chewbotcca/util/DatabaseHelper.java +++ b/src/main/java/pw/chew/chewbotcca/util/DatabaseHelper.java @@ -20,7 +20,9 @@ import org.hibernate.boot.MetadataSources; import org.hibernate.boot.registry.StandardServiceRegistry; import org.hibernate.boot.registry.StandardServiceRegistryBuilder; +import pw.chew.mlb.models.Bet; import pw.chew.mlb.models.Channel; +import pw.chew.mlb.models.Profile; import java.io.File; @@ -36,6 +38,8 @@ public static void openConnection() { sessionFactory = new MetadataSources(registry) // MLB Bot - Change to channel .addAnnotatedClass(Channel.class) + .addAnnotatedClass(Profile.class) + .addAnnotatedClass(Bet.class) .buildMetadata() .buildSessionFactory(); } catch (Exception e) { diff --git a/src/main/java/pw/chew/mlb/MLBBot.java b/src/main/java/pw/chew/mlb/MLBBot.java index 488a813..43c0876 100644 --- a/src/main/java/pw/chew/mlb/MLBBot.java +++ b/src/main/java/pw/chew/mlb/MLBBot.java @@ -13,6 +13,7 @@ import pw.chew.chewbotcca.util.DatabaseHelper; import pw.chew.chewbotcca.util.RestClient; import pw.chew.mlb.commands.AdminCommand; +import pw.chew.mlb.commands.BettingCommand; import pw.chew.mlb.commands.ConfigCommand; import pw.chew.mlb.commands.PlanGameCommand; import pw.chew.mlb.commands.ScoreCommand; @@ -64,7 +65,7 @@ public static void main(String[] args) throws IOException { new StartGameCommand(), new StopGameCommand(), new ScoreCommand(), new SetInfoCommand(), new ConfigCommand(), new PlanGameCommand() , // Util Commands - new StandingsCommand() + new StandingsCommand(), new BettingCommand() ); //client.forceGuildOnly("148195924567392257"); diff --git a/src/main/java/pw/chew/mlb/commands/BettingCommand.java b/src/main/java/pw/chew/mlb/commands/BettingCommand.java new file mode 100644 index 0000000..1c83a07 --- /dev/null +++ b/src/main/java/pw/chew/mlb/commands/BettingCommand.java @@ -0,0 +1,245 @@ +package pw.chew.mlb.commands; + +import com.jagrosh.jdautilities.command.SlashCommand; +import com.jagrosh.jdautilities.command.SlashCommandEvent; +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; +import net.dv8tion.jda.api.utils.TimeFormat; +import org.hibernate.Transaction; +import pw.chew.chewbotcca.util.DatabaseHelper; +import pw.chew.mlb.models.Bet; +import pw.chew.mlb.models.BetKind; +import pw.chew.mlb.models.Profile; +import pw.chew.mlb.objects.BetHelper; +import pw.chew.mlb.objects.GameBlurb; +import pw.chew.mlb.objects.GameState; +import pw.chew.mlb.util.AutocompleteUtil; + +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.List; + +public class BettingCommand extends SlashCommand { + public BettingCommand() { + this.name = "betting"; + this.help = "Bet on a team"; + this.children = new SlashCommand[]{ + new BettingProfileSubCommand(), + new BettingBetSubCommand(), + new BettingClaimSubCommand() + }; + } + + @Override + protected void execute(SlashCommandEvent event) { + // unused for children + } + + public static class BettingProfileSubCommand extends SlashCommand { + public BettingProfileSubCommand() { + this.name = "profile"; + this.help = "View your betting profile"; + } + + @Override + protected void execute(SlashCommandEvent event) { + // try to get profile + Profile profile = BetHelper.retrieveProfile(event.getUser().getIdLong()); + List bets = BetHelper.retrieveBets(event.getUser().getIdLong()); + + // build embed + event.replyEmbeds(buildProfileEmbed(event, profile, bets)).setEphemeral(true).queue(); + } + + private MessageEmbed buildProfileEmbed(SlashCommandEvent event, Profile profile, List bets) { + EmbedBuilder embed = new EmbedBuilder(); + embed.setTitle("Betting Profile for " + event.getUser().getName()); + embed.addField("Credits", profile.getCredits() + "", true); + + if (event.getMember() != null) { + embed.setColor(event.getMember().getColor()); + } + + // get 5 most recent bets + List betString = new ArrayList<>(); + for (int i = 0; i < 5 && i < bets.size(); i++) { + Bet bet = bets.get(i); + betString.add("%s | %s%s - %s".formatted( + TimeFormat.DATE_SHORT.format(bet.getCreatedAt()), + bet.amount() > 0 ? "+" : "", + bet.amount(), + bet.getReason() + )); + } + + embed.addField("Recent Bets", String.join("\n", betString), false); + + return embed.build(); + } + } + + public static class BettingClaimSubCommand extends SlashCommand { + public BettingClaimSubCommand() { + this.name = "claim"; + this.help = "Claim your free daily credits"; + } + + @Override + protected void execute(SlashCommandEvent event) { + Bet recentDailyCredit = BetHelper.getRecentDailyCredit(event.getUser().getIdLong()); + long lastRecentDaily = 0; + + if (recentDailyCredit != null) { + lastRecentDaily = recentDailyCredit.getCreatedAt().getEpochSecond(); + } + + long now = OffsetDateTime.now().toEpochSecond(); + long diff = now - lastRecentDaily; + + // must be less than 24 hours + if (diff < 86400) { + long canClaimAt = lastRecentDaily + 86400; + + event.reply("You already claimed your daily credits! You can claim again ").setEphemeral(true).queue(); + return; + } + + // add daily credit + BetHelper.addDailyCredit(event.getUser().getIdLong()); + + event.reply("You have claimed your daily credits!").setEphemeral(true).queue(); + } + } + + public static class BettingBetSubCommand extends SlashCommand { + public BettingBetSubCommand() { + this.name = "bet"; + this.help = "Bet on a team"; + this.options = List.of( + new OptionData(OptionType.INTEGER, "team", "Which team to bet on", true, true), + new OptionData(OptionType.INTEGER, "date", "Which game to bet on", true, true), + new OptionData(OptionType.INTEGER, "amount", "How much to bet. 0 to remove bet.", true, false) + .setMinValue(0) + ); + } + + @Override + protected void execute(SlashCommandEvent event) { + // options + int teamId = (int) event.optLong("team", 0); + int gamePk = (int) event.optLong("date", 0); + int amount = (int) event.optLong("amount", 0); + + // Check the game status + GameState state = GameState.fromPk(String.valueOf(gamePk)); + if (state.isFinal()) { + event.reply("This game is already over!").setEphemeral(true).queue(); + return; + } + if (state.inning() > 4) { + event.reply("Bets cannot be placed or changed past the 4th inning! To see your bet, run `/betting placed`").setEphemeral(true).queue(); + return; + } + + // start session + var session = DatabaseHelper.getSessionFactory().openSession(); + + // check for a bet, check by user_id, game_pk, and team_id + Bet bet = session.createQuery("from Bet where userId = :userId and gamePk = :gamePk and teamId = :teamId", Bet.class) + .setParameter("userId", event.getUser().getIdLong()) + .setParameter("gamePk", gamePk) + .setParameter("teamId", teamId) + .uniqueResult(); + + // Ensure we have enough credit to bet + Profile user = BetHelper.retrieveProfile(event.getUser().getIdLong()); + + // Get game blurb + GameBlurb blurb = new GameBlurb(gamePk + ""); + + // if bet exists, update it + if (bet == null) { + if (amount == 0) { + event.reply("You must bet at least 1 credit!").setEphemeral(true).queue(); + return; + } + + if (user.getCredits() < amount) { + event.reply("You don't have enough credits to bet that much! You only have " + user.getCredits() + " credits!").setEphemeral(true).queue(); + return; + } + + String team = blurb.away().id() == teamId ? blurb.away().name() : blurb.home().name(); + + Bet newBet = new Bet(); + newBet.setUserId(event.getUser().getIdLong()); + newBet.setGamePk(gamePk); + newBet.setTeamId(teamId); + newBet.setBet(amount); + newBet.setKind(BetKind.PENDING); + newBet.setReason("Bet on " + team + " for " + blurb.name()); + + user.setCredits(user.getCredits() - amount); + + Transaction trans = session.beginTransaction(); + session.save(newBet); + session.update(user); + trans.commit(); + + event.reply("Bet placed!").setEphemeral(true).queue(); + } else if (amount == 0) { + int currentBet = bet.getBet(); + + Transaction trans = session.beginTransaction(); + session.delete(bet); + + user.setCredits(user.getCredits() + currentBet); + session.update(user); + + trans.commit(); + + event.reply("Bet removed!").setEphemeral(true).queue(); + } else { + int currentBet = bet.getBet(); + int creditsToDeduct = amount - currentBet; + if (amount > currentBet && creditsToDeduct > user.getCredits()) { + event.reply("You don't have enough credits to bet that much! You only have " + user.getCredits() + " credits!").queue(); + return; + } + + user.setCredits(user.getCredits() - creditsToDeduct); + bet.setBet(amount); + + Transaction trans = session.beginTransaction(); + session.update(bet); + session.update(user); + trans.commit(); + + event.reply("Bet updated!").setEphemeral(true).queue(); + } + + session.close(); + } + + @Override + public void onAutoComplete(CommandAutoCompleteInteractionEvent event) { + event.replyChoices(AutocompleteUtil.handleInput(event)).queue(); + } + } + + public static class BettingPlacedSubCommand extends SlashCommand { + public BettingPlacedSubCommand() { + this.name = "placed"; + this.help = "View your placed bets"; + } + + @Override + protected void execute(SlashCommandEvent event) { + List bets = BetHelper.retrieveBets(event.getUser().getIdLong()); + + } + } +} diff --git a/src/main/java/pw/chew/mlb/commands/PlanGameCommand.java b/src/main/java/pw/chew/mlb/commands/PlanGameCommand.java index 1d4c9ca..6ff016a 100644 --- a/src/main/java/pw/chew/mlb/commands/PlanGameCommand.java +++ b/src/main/java/pw/chew/mlb/commands/PlanGameCommand.java @@ -25,6 +25,8 @@ import java.time.ZoneOffset; import java.util.ArrayList; import java.util.Arrays; +import java.util.Calendar; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; diff --git a/src/main/java/pw/chew/mlb/listeners/GameFeedHandler.java b/src/main/java/pw/chew/mlb/listeners/GameFeedHandler.java index 05aeea7..eed24aa 100644 --- a/src/main/java/pw/chew/mlb/listeners/GameFeedHandler.java +++ b/src/main/java/pw/chew/mlb/listeners/GameFeedHandler.java @@ -19,7 +19,9 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import pw.chew.mlb.commands.AdminCommand; +import pw.chew.mlb.models.Bet; import pw.chew.mlb.objects.ActiveGame; +import pw.chew.mlb.objects.BetHelper; import pw.chew.mlb.objects.ChannelConfig; import pw.chew.mlb.objects.GameState; @@ -583,6 +585,10 @@ public static void endGame(String gamePk, GameState currentState, String scoreca // Remove the game thread removeThread(gamePk); + + // Handle bets + LoggerFactory.getLogger(GameFeedHandler.class).debug("Awarding bets for game " + gamePk); + BetHelper.awardWinners(gamePk, currentState.winningTeam()); } /** diff --git a/src/main/java/pw/chew/mlb/models/Bet.kt b/src/main/java/pw/chew/mlb/models/Bet.kt new file mode 100644 index 0000000..6511040 --- /dev/null +++ b/src/main/java/pw/chew/mlb/models/Bet.kt @@ -0,0 +1,49 @@ +package pw.chew.mlb.models + +import java.time.Instant +import javax.persistence.* + +@Entity +@Table(name = "bets") +open class Bet { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false) + open var id: Int? = null + + @Column(name = "user_id", nullable = false) + open var userId: Long? = null + + @Column(name = "bet", nullable = false) + open var bet: Int = 0 + + @Column(name = "payout", nullable = false) + open var payout: Int = 0 + + @Column(name = "game_pk") + open var gamePk: Int? = null + + @Column(name = "team_id") + open var teamId: Int? = null + + @Enumerated(EnumType.STRING) + @Column(name = "kind", nullable = false) + open var kind: BetKind = BetKind.PENDING + + @Column(name = "reason", nullable = false, length = 128) + open var reason: String = "" + + @Column(name = "created_at", nullable = false) + open var createdAt: Instant = Instant.now() + + fun amount(): Int { + return payout - bet + } +} + +enum class BetKind { + AUTOMATED, + PENDING, + WIN, + LOSS +} \ No newline at end of file diff --git a/src/main/java/pw/chew/mlb/models/Profile.kt b/src/main/java/pw/chew/mlb/models/Profile.kt new file mode 100644 index 0000000..e4fdafd --- /dev/null +++ b/src/main/java/pw/chew/mlb/models/Profile.kt @@ -0,0 +1,17 @@ +package pw.chew.mlb.models + +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.Id +import javax.persistence.Table + +@Entity +@Table(name = "profiles") +open class Profile { + @Id + @Column(name = "id", nullable = false) + open var id: Long? = null + + @Column(name = "credits", nullable = false) + open var credits: Int = 100 +} diff --git a/src/main/java/pw/chew/mlb/objects/BetHelper.java b/src/main/java/pw/chew/mlb/objects/BetHelper.java new file mode 100644 index 0000000..96727db --- /dev/null +++ b/src/main/java/pw/chew/mlb/objects/BetHelper.java @@ -0,0 +1,186 @@ +package pw.chew.mlb.objects; + +import org.hibernate.Session; +import org.hibernate.Transaction; +import org.jetbrains.annotations.Nullable; +import org.slf4j.LoggerFactory; +import pw.chew.chewbotcca.util.DatabaseHelper; +import pw.chew.mlb.listeners.GameFeedHandler; +import pw.chew.mlb.models.Bet; +import pw.chew.mlb.models.BetKind; +import pw.chew.mlb.models.Profile; + +import java.util.ArrayList; +import java.util.List; + +public class BetHelper { + public static void awardWinners(String gamePk, int winningTeam) { + var session = DatabaseHelper.getSessionFactory().openSession(); + + // get all sessions where bet's gamePk is the gamePk + var bets = session.createQuery("from Bet where gamePk = :gamePk", Bet.class) + .setParameter("gamePk", Integer.parseInt(gamePk)) + .getResultList(); + + LoggerFactory.getLogger(BetHelper.class).debug("Bets for game {}: {}", gamePk, bets.size()); + + LoggerFactory.getLogger(BetHelper.class).debug("Winning team: {}", winningTeam); + int totalPoints = 0; + int winningPoints = 0; + + List winningBets = new ArrayList<>(); + List losingBets = new ArrayList<>(); + + for (Bet bet : bets) { + totalPoints += bet.amount(); + Integer teamId = bet.getTeamId(); + if (teamId == null) { + continue; + } + + if (teamId == winningTeam) { + winningBets.add(bet); + winningPoints += bet.amount(); + } else { + losingBets.add(bet); + } + } + + Transaction trans = session.beginTransaction(); + + // first let's set all the losers and make sure they lose their points + for (Bet bet : losingBets) { + bet.setPayout(0); + bet.setKind(BetKind.LOSS); + } + + // now we give winners their points + for (Bet bet : winningBets) { + bet.setPayout((int) Math.floor((double) bet.amount() / winningPoints * totalPoints)); + bet.setKind(BetKind.WIN); + } + + LoggerFactory.getLogger(GameFeedHandler.class).debug("Awarding winners: {}", winningBets); + + // save all the bets + trans.commit(); + } + + /** + * Retrieves a profile for the provided user id. + * If there is no profile present, it will create a profile and award a bet. + * + * @param userId the user id + * @return the profile + */ + public static Profile retrieveProfile(long userId) { + var session = DatabaseHelper.getSessionFactory().openSession(); + Profile profile = session.find(Profile.class, userId); + if (profile == null) { + Transaction trans = session.beginTransaction(); + profile = new Profile(); + profile.setId(userId); + session.save(profile); + + // add initial betting credits + addAutomatedBet(userId, 100, "Initial betting credits", session); + + // set initial credits in the profile + profile.setCredits(100); + + trans.commit(); + } + session.close(); + return profile; + } + + /** + * Adds an automated bet for the user. + * + * @param userId the user id + * @param amount the amount to add to the account + * @param reason the reason for the bet + * @param session the session to use, null if to open a new session. if a session is provided, the transaction won't commit + * @return the bet + */ + public static Bet addAutomatedBet(long userId, int amount, String reason, @Nullable Session session) { + // open session and transaction + boolean nullSession = false; + if (session == null) { + session = DatabaseHelper.getSessionFactory().openSession(); + nullSession = true; + } + + Transaction trans = session.beginTransaction(); + + // add initial betting credits + Bet bet = new Bet(); + bet.setKind(BetKind.AUTOMATED); + bet.setBet(0); // 0 bet because the user didn't do anything + bet.setPayout(amount); + bet.setReason(reason); + bet.setUserId(userId); + + session.save(bet); + + // Commit transaction and close session if we opened to begin with + if (nullSession) { + trans.commit(); + session.close(); + } + + return bet; + } + + /** + * Adds the daily credit to the user. + * + * @param userId + */ + public static void addDailyCredit(long userId) { + var session = DatabaseHelper.getSessionFactory().openSession(); + + // Add daily credits + addAutomatedBet(userId, 10, "Daily Credits", session); + + Profile profile = BetHelper.retrieveProfile(userId); + profile.setCredits(profile.getCredits() + 10); + + Transaction trans = session.beginTransaction(); + session.update(profile); + trans.commit(); + session.close(); + } + + public static Bet getRecentDailyCredit(long userId) { + var session = DatabaseHelper.getSessionFactory().openSession(); + + // get Bets where user_id == userId + List bets = session.createQuery("from Bet where userId = :userId and kind = :kind and reason = :reason order by createdAt desc", Bet.class) + .setParameter("userId", userId) + .setParameter("reason", "Daily Credits") + .setParameter("kind", BetKind.AUTOMATED) + .getResultList(); + + session.close(); + + if (bets.isEmpty()) { + return null; + } + + return bets.get(0); + } + + public static List retrieveBets(long userId) { + var session = DatabaseHelper.getSessionFactory().openSession(); + + // get Bets where user_id == userId + List bets = session.createQuery("from Bet where userId = :userId order by createdAt desc", Bet.class) + .setParameter("userId", userId) + .getResultList(); + + session.close(); + + return bets; + } +} diff --git a/src/main/java/pw/chew/mlb/objects/GameState.java b/src/main/java/pw/chew/mlb/objects/GameState.java index 3e55c8d..0a1ab8f 100644 --- a/src/main/java/pw/chew/mlb/objects/GameState.java +++ b/src/main/java/pw/chew/mlb/objects/GameState.java @@ -127,7 +127,7 @@ public OffsetDateTime officialDate() { * @return The current inning of the game */ public int inning() { - return lineScore().getInt("currentInning"); + return lineScore().optInt("currentInning", 0); } /** @@ -539,6 +539,17 @@ public String decisions() { return String.join("\n", response); } + public int winningTeam() { + JSONObject scores = gameData().getJSONObject("liveData").getJSONObject("linescore").getJSONObject("teams"); + int homeRuns = scores.getJSONObject("home").getInt("runs"); + int awayRuns = scores.getJSONObject("away").getInt("runs"); + + JSONObject homeTeam = gameData().getJSONObject("gameData").getJSONObject("teams").getJSONObject("home"); + JSONObject awayTeam = gameData().getJSONObject("gameData").getJSONObject("teams").getJSONObject("away"); + + return (homeRuns > awayRuns ? homeTeam : awayTeam).getInt("id"); + } + /** * Builds a summary of the game. This includes the score, who won/is winning, and the current inning. * Present tense if the game is ongoing, past tense if the game is over.