From 0b36bc9e9312d7ae64302642ac1e3df1b609dff6 Mon Sep 17 00:00:00 2001 From: FernandoJVideira <03.pleaser-minster@icloud.com> Date: Tue, 17 Mar 2026 16:44:18 +0000 Subject: [PATCH] feat: rps commands --- internal/commands/commands.go | 6 + internal/commands/fun/public/rps.go | 106 ++++++++++++++ .../commands/fun/public/rpsleaderboard.go | 73 ++++++++++ internal/commands/fun/public/rpsstats.go | 73 ++++++++++ internal/commands/fun/registry.go | 10 ++ internal/commands/help/help.go | 50 ++++++- internal/db/repos/rpsrepo/repo.go | 76 ++++++++++ internal/db/services/rps/service.go | 136 ++++++++++++++++++ internal/db/services/services.go | 5 +- main.go | 6 +- 10 files changed, 538 insertions(+), 3 deletions(-) create mode 100644 internal/commands/fun/public/rps.go create mode 100644 internal/commands/fun/public/rpsleaderboard.go create mode 100644 internal/commands/fun/public/rpsstats.go create mode 100644 internal/db/repos/rpsrepo/repo.go create mode 100644 internal/db/services/rps/service.go diff --git a/internal/commands/commands.go b/internal/commands/commands.go index 2142325..7bc2b3f 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -13,6 +13,9 @@ var AllCommands = []*discordgo.ApplicationCommand{ fun.Joke, fun.Coinflip, fun.Dice, + fun.Rps, + fun.RpsStats, + fun.RpsLeaderboard, help.Help, cmdlevel.Slvl, cmdlevel.Rank, @@ -25,6 +28,9 @@ var handlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCre "joke": fun.JokeHandler, "coinflip": fun.CoinflipHandler, "dice": fun.DiceHandler, + "rps": fun.RpsHandler, + "rpsstats": fun.RpsStatsHandler, + "rpsleaderboard": fun.RpsLeaderboardHandler, "help": help.HelpHandler, "slvl": cmdlevel.SlvlHandler, "rank": cmdlevel.RankHandler, diff --git a/internal/commands/fun/public/rps.go b/internal/commands/fun/public/rps.go new file mode 100644 index 0000000..a3ae1df --- /dev/null +++ b/internal/commands/fun/public/rps.go @@ -0,0 +1,106 @@ +package public + +import ( + "context" + "strconv" + + "velox-bot/internal/commands/fun/shared" + "velox-bot/internal/db/services" + rpssvc "velox-bot/internal/db/services/rps" + + "github.com/bwmarrin/discordgo" +) + +var Rps = &discordgo.ApplicationCommand{ + Name: "rps", + Description: "Plays Rock Paper Scissors with you", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionString, + Name: "hand", + Description: "Choose between ✌️, ✋ or 🤜", + Required: true, + Choices: []*discordgo.ApplicationCommandOptionChoice{ + {Name: "✌️ - Scissors", Value: string(rpssvc.HandScissors)}, + {Name: "✋ - Paper", Value: string(rpssvc.HandPaper)}, + {Name: "🤜 - Rock", Value: string(rpssvc.HandRock)}, + }, + }, + }, + Contexts: &[]discordgo.InteractionContextType{ + discordgo.InteractionContextGuild, + }, +} + +func RpsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + if i.GuildID == "" { + shared.Respond(s, i, "This command can only be used in a server.") + return + } + if services.Global == nil || services.Global.RPS == nil { + shared.RespondEphemeral(s, i, "RPS service is not available.") + return + } + data := i.ApplicationCommandData() + if len(data.Options) == 0 { + shared.RespondEphemeral(s, i, "Invalid hand! Please choose between ✌️, ✋ or 🤜") + return + } + handStr := data.Options[0].StringValue() + userHand, ok := rpssvc.ParseHand(handStr) + if !ok { + shared.RespondEphemeral(s, i, "Invalid hand! Please choose between ✌️, ✋ or 🤜") + return + } + + guildID, err := strconv.ParseInt(i.GuildID, 10, 64) + if err != nil { + shared.RespondEphemeral(s, i, "Invalid guild.") + return + } + + user := i.User + if i.Member != nil && i.Member.User != nil { + user = i.Member.User + } + if user == nil { + return + } + userID, err := strconv.ParseInt(user.ID, 10, 64) + if err != nil { + shared.RespondEphemeral(s, i, "Invalid user.") + return + } + + out, err := services.Global.RPS.Play(context.Background(), guildID, userID, userHand) + if err != nil { + shared.RespondEphemeral(s, i, "Failed to play RPS.") + return + } + + color := 0xEF4444 + switch out.Result { + case rpssvc.ResultWin: + color = 0x22C55E + case rpssvc.ResultTie: + color = 0xF59E0B + } + + embed := &discordgo.MessageEmbed{ + Title: string(out.Result), + Description: user.Mention() + " vs bot", + Color: color, + Fields: []*discordgo.MessageEmbedField{ + {Name: "Your hand", Value: out.UserHand.String(), Inline: true}, + {Name: "Bot hand", Value: out.BotHand.String(), Inline: true}, + {Name: "Score", Value: strconv.Itoa(out.Score), Inline: true}, + }, + } + + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{embed}, + }, + }) +} diff --git a/internal/commands/fun/public/rpsleaderboard.go b/internal/commands/fun/public/rpsleaderboard.go new file mode 100644 index 0000000..40c27c5 --- /dev/null +++ b/internal/commands/fun/public/rpsleaderboard.go @@ -0,0 +1,73 @@ +package public + +import ( + "context" + "fmt" + "strconv" + + "velox-bot/internal/commands/fun/shared" + "velox-bot/internal/db/services" + + "github.com/bwmarrin/discordgo" +) + +var RpsLeaderboard = &discordgo.ApplicationCommand{ + Name: "rpsleaderboard", + Description: "Shows the RPS leaderboard", + Contexts: &[]discordgo.InteractionContextType{ + discordgo.InteractionContextGuild, + }, +} + +func RpsLeaderboardHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + if i.GuildID == "" { + shared.Respond(s, i, "This command can only be used in a server.") + return + } + if services.Global == nil || services.Global.RPS == nil { + shared.RespondEphemeral(s, i, "RPS service is not available.") + return + } + guildID, err := strconv.ParseInt(i.GuildID, 10, 64) + if err != nil { + shared.RespondEphemeral(s, i, "Invalid guild.") + return + } + + top, err := services.Global.RPS.TopScores(context.Background(), guildID, 10) + if err != nil { + shared.RespondEphemeral(s, i, "Failed to load leaderboard.") + return + } + if len(top) == 0 { + shared.RespondEphemeral(s, i, "No leaderboard data yet.") + return + } + + fields := make([]*discordgo.MessageEmbedField, 0, len(top)) + for idx, entry := range top { + display := fmt.Sprintf("<@%d>", entry.UserID) + if u, err := s.User(strconv.FormatInt(entry.UserID, 10)); err == nil && u != nil && u.Username != "" { + display = u.Username + } + fields = append(fields, &discordgo.MessageEmbedField{ + Name: fmt.Sprintf("%d. %s", idx+1, display), + Value: fmt.Sprintf("Score: **%d**", entry.Score), + Inline: false, + }) + } + + embed := &discordgo.MessageEmbed{ + Title: "RPS Leaderboard", + Description: fmt.Sprintf("Top %d players in this server", len(top)), + Color: 0xA855F7, + Fields: fields, + } + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{embed}, + }, + }) +} + diff --git a/internal/commands/fun/public/rpsstats.go b/internal/commands/fun/public/rpsstats.go new file mode 100644 index 0000000..9eac696 --- /dev/null +++ b/internal/commands/fun/public/rpsstats.go @@ -0,0 +1,73 @@ +package public + +import ( + "context" + "strconv" + + "velox-bot/internal/commands/fun/shared" + "velox-bot/internal/db/services" + + "github.com/bwmarrin/discordgo" +) + +var RpsStats = &discordgo.ApplicationCommand{ + Name: "rpsstats", + Description: "Shows your RPS stats", + Contexts: &[]discordgo.InteractionContextType{ + discordgo.InteractionContextGuild, + }, +} + +func RpsStatsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + if i.GuildID == "" { + shared.Respond(s, i, "This command can only be used in a server.") + return + } + if services.Global == nil || services.Global.RPS == nil { + shared.RespondEphemeral(s, i, "RPS service is not available.") + return + } + guildID, err := strconv.ParseInt(i.GuildID, 10, 64) + if err != nil { + shared.RespondEphemeral(s, i, "Invalid guild.") + return + } + user := i.User + if i.Member != nil && i.Member.User != nil { + user = i.Member.User + } + if user == nil { + return + } + userID, err := strconv.ParseInt(user.ID, 10, 64) + if err != nil { + shared.RespondEphemeral(s, i, "Invalid user.") + return + } + + score, ok, err := services.Global.RPS.GetScore(context.Background(), guildID, userID) + if err != nil { + shared.RespondEphemeral(s, i, "Failed to load stats.") + return + } + if !ok { + shared.RespondEphemeral(s, i, "You haven't played RPS yet!") + return + } + + embed := &discordgo.MessageEmbed{ + Title: "RPS Stats", + Description: user.Mention(), + Color: 0x3B82F6, + Fields: []*discordgo.MessageEmbedField{ + {Name: "Score", Value: strconv.Itoa(score), Inline: true}, + }, + } + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{embed}, + }, + }) +} + diff --git a/internal/commands/fun/registry.go b/internal/commands/fun/registry.go index 2808f4f..410efa9 100644 --- a/internal/commands/fun/registry.go +++ b/internal/commands/fun/registry.go @@ -12,6 +12,9 @@ var ( Joke *discordgo.ApplicationCommand = public.Joke Coinflip *discordgo.ApplicationCommand = public.Coinflip Dice *discordgo.ApplicationCommand = public.Dice + Rps *discordgo.ApplicationCommand = public.Rps + RpsStats *discordgo.ApplicationCommand = public.RpsStats + RpsLeaderboard *discordgo.ApplicationCommand = public.RpsLeaderboard ) // Handlers @@ -21,3 +24,10 @@ func CoinflipHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.CoinflipHandler(s, i) } func DiceHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.DiceHandler(s, i) } +func RpsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { public.RpsHandler(s, i) } +func RpsStatsHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + public.RpsStatsHandler(s, i) +} +func RpsLeaderboardHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + public.RpsLeaderboardHandler(s, i) +} diff --git a/internal/commands/help/help.go b/internal/commands/help/help.go index 346c845..b259579 100644 --- a/internal/commands/help/help.go +++ b/internal/commands/help/help.go @@ -47,12 +47,36 @@ func HelpHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { Fields: []*discordgo.MessageEmbedField{ { Name: "Fun Commands", - Value: "Here are the fun commands for the bot.", + Value: "Small utility + games.", }, { Name: "/ping", Value: "Pong!", }, + { + Name: "/joke [type]", + Value: "Get a random joke (optional category).", + }, + { + Name: "/coinflip", + Value: "Flip a coin.", + }, + { + Name: "/dice ", + Value: "Roll dice expressions (e.g. `d20`, `2d20+1`, `2#d20+1`).", + }, + { + Name: "/rps ", + Value: "Play Rock Paper Scissors (server only).", + }, + { + Name: "/rpsstats", + Value: "Show your RPS score (server only).", + }, + { + Name: "/rpsleaderboard", + Value: "Show top RPS scores (server only).", + }, { Name: "/help", Value: "Get help with the bot.", @@ -69,6 +93,30 @@ func HelpHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { Name: "/slvl set", Value: "Set the level of a user.", }, + { + Name: "/slvl set-channel", + Value: "Set the channel for level-up messages.", + }, + { + Name: "/slvl set-message", + Value: "Set the custom level-up message.", + }, + { + Name: "/slvl set-reward", + Value: "Set a role reward for a level.", + }, + { + Name: "/rank [user]", + Value: "Show a rank card (server only).", + }, + { + Name: "/leaderboard", + Value: "Show top levels (server only).", + }, + { + Name: "/rewards", + Value: "List configured level rewards (server only).", + }, }, Author: &author, } diff --git a/internal/db/repos/rpsrepo/repo.go b/internal/db/repos/rpsrepo/repo.go new file mode 100644 index 0000000..47156c4 --- /dev/null +++ b/internal/db/repos/rpsrepo/repo.go @@ -0,0 +1,76 @@ +package rpsrepo + +import ( + "context" + "database/sql" +) + +type Repo struct { + db *sql.DB +} + +func NewRepo(db *sql.DB) *Repo { + return &Repo{db: db} +} + +func (r *Repo) GetScore(ctx context.Context, guildID int64, userID int64) (int, bool, error) { + var score int + err := r.db.QueryRowContext(ctx, ` + SELECT score + FROM rps + WHERE guild_id = $1 AND user_id = $2 + `, guildID, userID).Scan(&score) + if err == sql.ErrNoRows { + return 0, false, nil + } + if err != nil { + return 0, false, err + } + return score, true, nil +} + +func (r *Repo) SetScore(ctx context.Context, guildID int64, userID int64, score int) error { + _, err := r.db.ExecContext(ctx, ` + INSERT INTO rps (guild_id, user_id, score) + VALUES ($1, $2, $3) + ON CONFLICT (guild_id, user_id) + DO UPDATE SET score = EXCLUDED.score + `, guildID, userID, score) + return err +} + +type ScoreEntry struct { + UserID int64 + Score int +} + +func (r *Repo) TopScores(ctx context.Context, guildID int64, limit int) ([]ScoreEntry, error) { + if limit <= 0 { + limit = 10 + } + rows, err := r.db.QueryContext(ctx, ` + SELECT user_id, score + FROM rps + WHERE guild_id = $1 + ORDER BY score DESC, user_id ASC + LIMIT $2 + `, guildID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + out := make([]ScoreEntry, 0, limit) + for rows.Next() { + var e ScoreEntry + if err := rows.Scan(&e.UserID, &e.Score); err != nil { + return nil, err + } + out = append(out, e) + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} + diff --git a/internal/db/services/rps/service.go b/internal/db/services/rps/service.go new file mode 100644 index 0000000..9bf9f09 --- /dev/null +++ b/internal/db/services/rps/service.go @@ -0,0 +1,136 @@ +package rps + +import ( + "context" + "crypto/rand" + "errors" + "math/big" + "strings" + + "velox-bot/internal/db/repos/rpsrepo" +) + +type Repo interface { + GetScore(ctx context.Context, guildID int64, userID int64) (int, bool, error) + SetScore(ctx context.Context, guildID int64, userID int64, score int) error + TopScores(ctx context.Context, guildID int64, limit int) ([]rpsrepo.ScoreEntry, error) +} + +type Service struct { + repo Repo +} + +func New(repo Repo) *Service { + return &Service{repo: repo} +} + +type Hand string + +const ( + HandScissors Hand = "✌️" + HandPaper Hand = "✋" + HandRock Hand = "🤜" +) + +func (h Hand) String() string { return string(h) } + +func ParseHand(s string) (Hand, bool) { + switch strings.TrimSpace(s) { + case string(HandScissors): + return HandScissors, true + case string(HandPaper): + return HandPaper, true + case string(HandRock): + return HandRock, true + default: + return "", false + } +} + +func RandomHand() (Hand, error) { + n, err := rand.Int(rand.Reader, big.NewInt(3)) + if err != nil { + return "", err + } + switch n.Int64() { + case 0: + return HandScissors, nil + case 1: + return HandPaper, nil + default: + return HandRock, nil + } +} + +type GameResult string + +const ( + ResultWin GameResult = "You won!" + ResultLose GameResult = "You lost!" + ResultTie GameResult = "It's a tie!" +) + +func DetermineResult(user Hand, bot Hand) GameResult { + if user == bot { + return ResultTie + } + switch user { + case HandRock: + if bot == HandScissors { + return ResultWin + } + case HandPaper: + if bot == HandRock { + return ResultWin + } + case HandScissors: + if bot == HandPaper { + return ResultWin + } + } + return ResultLose +} + +type PlayOutput struct { + UserHand Hand + BotHand Hand + Result GameResult + Score int +} + +func (s *Service) Play(ctx context.Context, guildID int64, userID int64, userHand Hand) (PlayOutput, error) { + botHand, err := RandomHand() + if err != nil { + return PlayOutput{}, err + } + result := DetermineResult(userHand, botHand) + + score, _, err := s.repo.GetScore(ctx, guildID, userID) + if err != nil { + return PlayOutput{}, err + } + if result == ResultWin { + score++ + if err := s.repo.SetScore(ctx, guildID, userID, score); err != nil { + return PlayOutput{}, err + } + } + + return PlayOutput{ + UserHand: userHand, + BotHand: botHand, + Result: result, + Score: score, + }, nil +} + +func (s *Service) GetScore(ctx context.Context, guildID int64, userID int64) (int, bool, error) { + return s.repo.GetScore(ctx, guildID, userID) +} + +func (s *Service) TopScores(ctx context.Context, guildID int64, limit int) ([]rpsrepo.ScoreEntry, error) { + return s.repo.TopScores(ctx, guildID, limit) +} + +var ErrGuildOnly = errors.New("guild only") + diff --git a/internal/db/services/services.go b/internal/db/services/services.go index 29d0c97..f77acaf 100644 --- a/internal/db/services/services.go +++ b/internal/db/services/services.go @@ -3,19 +3,22 @@ package services import ( "velox-bot/internal/db/services/level" "velox-bot/internal/db/services/levelsettings" + "velox-bot/internal/db/services/rps" ) type Services struct { Level *level.Service LevelSettings *levelsettings.Service + RPS *rps.Service } var Global *Services -func NewServices(level *level.Service, levelSettings *levelsettings.Service) *Services { +func NewServices(level *level.Service, levelSettings *levelsettings.Service, rps *rps.Service) *Services { s := &Services{ Level: level, LevelSettings: levelSettings, + RPS: rps, } Global = s return s diff --git a/main.go b/main.go index 50090cd..665a88f 100644 --- a/main.go +++ b/main.go @@ -11,10 +11,12 @@ import ( "velox-bot/internal/config" "velox-bot/internal/db" "velox-bot/internal/db/repos/levelrepo" + "velox-bot/internal/db/repos/rpsrepo" "velox-bot/internal/db/repos/settingsrepo" "velox-bot/internal/db/services" "velox-bot/internal/db/services/level" "velox-bot/internal/db/services/levelsettings" + "velox-bot/internal/db/services/rps" ) func main() { @@ -33,9 +35,11 @@ func main() { levelRepo := levelrepo.NewRepo(db) settingsRepo := settingsrepo.NewRepo(db) + rpsRepo := rpsrepo.NewRepo(db) levelService := level.New(levelRepo, settingsRepo) levelSettingsService := levelsettings.New(settingsRepo) - services := services.NewServices(levelService, levelSettingsService) + rpsService := rps.New(rpsRepo) + services := services.NewServices(levelService, levelSettingsService, rpsService) bot, err := bot.NewBot(config.BotToken, config.AppID, config.GuildID, commands.AllCommands, services) if err != nil {