diff --git a/src/commands/fun/eco.ts b/src/commands/fun/eco.ts index 5be316f..557b2aa 100644 --- a/src/commands/fun/eco.ts +++ b/src/commands/fun/eco.ts @@ -3,6 +3,7 @@ import {isAuthorized, getMoneyEmbed} from "./subcommands/eco-utils"; import {DailyCommand, PayCommand, GuildCommand, LeaderboardCommand} from "./subcommands/eco-core"; import {BuyCommand, ShopCommand} from "./subcommands/eco-shop"; import {MondayCommand} from "./subcommands/eco-extras"; +import {BetCommand} from "./subcommands/eco-bet"; export default new Command({ description: "Economy command for Monika.", @@ -16,7 +17,8 @@ export default new Command({ leaderboard: LeaderboardCommand, buy: BuyCommand, shop: ShopCommand, - monday: MondayCommand + monday: MondayCommand, + bet: BetCommand }, user: new Command({ description: "See how much money someone else has by using their user ID or pinging them.", diff --git a/src/commands/fun/subcommands/eco-bet.ts b/src/commands/fun/subcommands/eco-bet.ts new file mode 100644 index 0000000..01c6c96 --- /dev/null +++ b/src/commands/fun/subcommands/eco-bet.ts @@ -0,0 +1,190 @@ +import Command from "../../../core/command"; +import $ from "../../../core/lib"; +import {Storage} from "../../../core/structures"; +import {isAuthorized, getMoneyEmbed, getSendEmbed, ECO_EMBED_COLOR} from "./eco-utils"; +import {User} from "discord.js"; + +export const BetCommand = new Command({ + description: "Bet your Mons with other people.", + usage: " ", + run: "Who are you betting with?", + user: new Command({ + description: "User to bet with.", + // handles missing amount argument + async run({args, author, channel, guild}): Promise { + if (isAuthorized(guild, channel)) { + const target = args[0]; + + // handle invalid target + if (target.id == author.id) + return channel.send("You can't bet Mons with yourself!"); + else if (target.bot && process.argv[2] !== "dev") + return channel.send("You can't bet Mons with a bot!"); + + return channel.send("How much are you betting?"); + } + }, + number: new Command({ + description: "Amount of Mons to bet.", + // handles missing duration argument + async run({args, author, channel, guild}): Promise { + if (isAuthorized(guild, channel)) { + const sender = Storage.getUser(author.id); + const target = args[0] as User; + const receiver = Storage.getUser(target.id); + const amount = Math.floor(args[1]); + + // handle invalid target + if (target.id == author.id) + return channel.send("You can't bet Mons with yourself!"); + else if (target.bot && process.argv[2] !== "dev") + return channel.send("You can't bet Mons with a bot!"); + + // handle invalid amount + if (amount <= 0) + return channel.send("You must bet at least one Mon!"); + else if (sender.money < amount) + return channel.send("You don't have enough Mons for that.", getMoneyEmbed(author)); + else if (receiver.money < amount) + return channel.send("They don't have enough Mons for that.", getMoneyEmbed(target)); + + return channel.send("How long until the bet ends?"); + } + }, + any: new Command({ + description: "Duration of the bet.", + async run({client, args, author, message, channel, guild, askYesOrNo}): Promise { + if (isAuthorized(guild, channel)) { + // [Pertinence to make configurable on the fly.] + // Lower and upper bounds for bet + const durationBounds = { min:"1m", max:"1d" }; + + const sender = Storage.getUser(author.id); + const target = args[0] as User; + const receiver = Storage.getUser(target.id); + const amount = Math.floor(args[1]); + const duration = parseDuration(args[2].trim()); + + // handle invalid target + if (target.id == author.id) + return channel.send("You can't bet Mons with yourself!"); + else if (target.bot && process.argv[2] !== "dev") + return channel.send("You can't bet Mons with a bot!"); + + // handle invalid amount + if (amount <= 0) + return channel.send("You must bet at least one Mon!"); + else if (sender.money < amount) + return channel.send("You don't have enough Mons for that.", getMoneyEmbed(author)); + else if (receiver.money < amount) + return channel.send("They don't have enough Mons for that.", getMoneyEmbed(target)); + + // handle invalid duration + if (duration <= 0) + return channel.send("Invalid bet duration"); + else if (duration <= parseDuration(durationBounds.min)) + return channel.send(`Bet duration is too short, maximum duration is ${durationBounds.min}`); + else if (duration >= parseDuration(durationBounds.max)) + return channel.send(`Bet duration is too long, maximum duration is ${durationBounds.max}`); + + // Ask target whether or not they want to take the bet. + const takeBet = await askYesOrNo( + await channel.send(`<@${target.id}>, do you want to take this bet of ${$(amount).pluralise("Mon", "s")}`), + target.id + ); + + if (takeBet) { + // [MEDIUM PRIORITY: bet persistence to prevent losses in case of shutdown.] + // Remove amount money from both parts at the start to avoid duplication of money. + sender.money -= amount; + receiver.money -= amount; + // Very hacky solution for persistence but better than no solution, backup returns runs during the bot's setup code. + sender.ecoBetInsurance += amount; + receiver.ecoBetInsurance += amount; + Storage.save(); + + // Notify both users. + await channel.send(`<@${target.id}> has taken <@${author.id}>'s bet, the bet amount of ${$(amount).pluralise("Mon", "s")} has been deducted from each of them.`); + + // Wait for the duration of the bet. + client.setTimeout(async () => { + // In debug mode, saving the storage will break the references, so you have to redeclare sender and receiver for it to actually save. + const sender = Storage.getUser(author.id); + const receiver = Storage.getUser(target.id); + // [TODO: when D.JSv13 comes out, inline reply to clean up.] + // When bet is over, give a vote to ask people their thoughts. + const voteMsg = await channel.send(`VOTE: do you think that <@${target.id}> has won the bet?\nhttps://discord.com/channels/${guild!.id}/${channel.id}/${message.id}`); + await voteMsg.react("✅"); + await voteMsg.react("❌"); + + // Filter reactions to only collect the pertinent ones. + voteMsg.awaitReactions( + (reaction, user) => { + return ["✅", "❌"].includes(reaction.emoji.name); + }, + // [Pertinence to make configurable on the fly.] + { time: parseDuration("2m") } + ).then(reactions => { + // Count votes + const okReaction = reactions.get("✅"); + const noReaction = reactions.get("❌"); + const ok = okReaction ? (okReaction.count ?? 1) - 1 : 0; + const no = noReaction ? (noReaction.count ?? 1) - 1 : 0; + + if (ok > no) { + receiver.money += amount * 2; + channel.send(`By the people's votes, <@${target.id}> has won the bet that <@${author.id}> had sent them.`); + } + else if (ok < no) { + sender.money += amount * 2; + channel.send(`By the people's votes, <@${target.id}> has lost the bet that <@${author.id}> had sent them.`); + } + else { + sender.money += amount; + receiver.money += amount; + channel.send(`By the people's votes, <@${target.id}> couldn't be determined to have won or lost the bet that <@${author.id}> had sent them.`); + } + sender.ecoBetInsurance -= amount; + receiver.ecoBetInsurance -= amount; + Storage.save(); + }); + }, duration); + } + else + await channel.send(`<@${target.id}> has rejected your bet, <@${author.id}>`); + } + } + }) + }) + }) +}); + + +/** + * Parses a duration string into milliseconds + * Examples: + * - 3d -> 3 days -> 259200000ms + * - 2h -> 2 hours -> 7200000ms + * - 7m -> 7 minutes -> 420000ms + * - 3s -> 3 seconds -> 3000ms + */ +function parseDuration(duration: string): number { + // extract last char as unit + const unit = duration[duration.length - 1].toLowerCase(); + // get the rest as value + let value: number = +duration.substring(0, duration.length - 1); + + if (!["d","h","m","s"].includes(unit) || isNaN(value)) + return 0; + + if (unit === "d") + value *= 86400000; // 1000ms * 60s * 60m * 24h + else if (unit === "h") + value *= 3600000; // 1000ms * 60s * 60m + else if (unit === "m") + value *= 60000; // 1000ms * 60s + else if (unit === "s") + value *= 1000; // 1000ms + + return value; +} diff --git a/src/core/structures.ts b/src/core/structures.ts index 0e77494..8dd38a9 100644 --- a/src/core/structures.ts +++ b/src/core/structures.ts @@ -31,6 +31,7 @@ class User { public timezone: number | null; // This is for the standard timezone only, not the daylight savings timezone public daylightSavingsRegion: "na" | "eu" | "sh" | null; public todoList: {[timestamp: string]: string}; + public ecoBetInsurance: number; constructor(data?: GenericJSON) { this.money = select(data?.money, 0, Number); @@ -50,6 +51,7 @@ class User { } } } + this.ecoBetInsurance = select(data?.ecoBetInsurance, 0, Number); } } diff --git a/src/events/ready.ts b/src/events/ready.ts index b8467aa..f147fa2 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -1,7 +1,7 @@ import Event from "../core/event"; import {client} from "../index"; import $ from "../core/lib"; -import {Config} from "../core/structures"; +import {Config, Storage} from "../core/structures"; import {updateGlobalEmoteRegistry} from "../core/lib"; export default new Event<"ready">({ @@ -16,5 +16,17 @@ export default new Event<"ready">({ }); } updateGlobalEmoteRegistry(); + + // Run this setup block once to restore eco bet money in case the bot went down. (And I guess search the client for those users to let them know too.) + for (const id in Storage.users) { + const user = Storage.users[id]; + + if(user.ecoBetInsurance > 0) { + client.users.cache.get(id)?.send(`Because my system either crashed or restarted while you had a pending bet, the total amount of money that you bet, which was \`${user.ecoBetInsurance}\`, has been restored.`); + user.money += user.ecoBetInsurance; + user.ecoBetInsurance = 0; + } + } + Storage.save(); } });