From 4b8ae1f252a43e679123fe583a2284bd957bceb9 Mon Sep 17 00:00:00 2001 From: Malachy Byrne Date: Sun, 30 Mar 2025 16:26:48 +0100 Subject: [PATCH] git init --- application/bot.go | 105 ++++++++++++++++ application/commands.go | 104 ++++++++++++++++ dice/dice.go | 175 +++++++++++++++++++++++++++ dice/dice_test.go | 256 ++++++++++++++++++++++++++++++++++++++++ go.mod | 10 ++ go.sum | 12 ++ 6 files changed, 662 insertions(+) create mode 100644 application/bot.go create mode 100644 application/commands.go create mode 100644 dice/dice.go create mode 100644 dice/dice_test.go create mode 100644 go.mod create mode 100644 go.sum diff --git a/application/bot.go b/application/bot.go new file mode 100644 index 0000000..c547062 --- /dev/null +++ b/application/bot.go @@ -0,0 +1,105 @@ +package main + +import ( + "github.com/bwmarrin/discordgo" + "log" + "os" + "os/signal" + "strings" +) + +var s *discordgo.Session + +var prefix = os.Getenv("COMMAND_PREFIX") + +func init() { + token := os.Getenv("DISCORD_TOKEN") + var err error + s, err = discordgo.New("Bot " + token) + if err != nil { + log.Fatalf("Invalid bot parameters: %v", err) + } +} + +func init() { + s.AddHandler(func(s *discordgo.Session, i *discordgo.InteractionCreate) { + data := i.ApplicationCommandData() + if h, ok := slashCommandHandlers[data.Name]; ok { + h(s, i, parseOptions(data.Options)) + } + }) + s.AddHandler(func(s *discordgo.Session, m *discordgo.MessageCreate) { + if m.Author.ID == s.State.User.ID { + return + } + messageContent := strings.Split(m.Content, " ") + if !strings.HasPrefix(messageContent[0], prefix) { + return + } + messageContent[0] = messageContent[0][len(prefix):] + var response string + if fun, ok := dotCommandHandlers[messageContent[0]]; ok { + response = fun(messageContent[1:]) + } + _, err := s.ChannelMessageSendComplex(m.ChannelID, &discordgo.MessageSend{ + Content: response, + Reference: m.Reference(), + AllowedMentions: &discordgo.MessageAllowedMentions{ + RepliedUser: false, + }, + }) + if err != nil { + log.Fatalf("Error sending message: %v", err) + } + }) +} + +type optionMap = map[string]*discordgo.ApplicationCommandInteractionDataOption + +func parseOptions(options []*discordgo.ApplicationCommandInteractionDataOption) (om optionMap) { + om = make(optionMap) + for _, opt := range options { + om[opt.Name] = opt + } + return +} + +func main() { + s.AddHandler(func(s *discordgo.Session, i *discordgo.Ready) { + log.Printf("Logged in as: %v#%v", s.State.User.Username, s.State.User.Discriminator) + }) + err := s.Open() + if err != nil { + log.Fatalf("Cannot open Discord session: %v", err) + } + + log.Println("Adding commands") + registeredCommands := make([]*discordgo.ApplicationCommand, len(commands)) + for i, v := range commands { + cmd, err := s.ApplicationCommandCreate(s.State.User.ID, "", v) + if err != nil { + log.Panicf("Cannot create '%v' command: %v", v.Name, err) + } + registeredCommands[i] = cmd + } + + defer func(s *discordgo.Session) { + err := s.Close() + if err != nil { + + } + }(s) + + stop := make(chan os.Signal, 1) + signal.Notify(stop, os.Interrupt) + log.Println("Press Ctrl+C to exit") + <-stop + + log.Println("Shutting down...") + for _, v := range registeredCommands { + err := s.ApplicationCommandDelete(s.State.User.ID, "", v.ID) + if err != nil { + log.Panicf("Cannot delete '%v' command: %v", v.Name, err) + } + } +} diff --git a/application/commands.go b/application/commands.go new file mode 100644 index 0000000..d1fd569 --- /dev/null +++ b/application/commands.go @@ -0,0 +1,104 @@ +package main + +import ( + "fmt" + "github.com/bwmarrin/discordgo" + "log" + "strings" + "treerazer/dice" +) + +var ( + commands = []*discordgo.ApplicationCommand{ + { + Name: "roll", + Description: "Roll dice", + Options: []*discordgo.ApplicationCommandOption{ + { + Name: "dice", + Description: "Dice to roll", + Type: discordgo.ApplicationCommandOptionString, + Required: true, + }, + }, + }, + } + + slashCommandHandlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate, opts optionMap){ + "roll": func(s *discordgo.Session, i *discordgo.InteractionCreate, opts optionMap) { + err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: roll(opts), + }, + }) + if err != nil { + log.Fatalf("Error responding to interaction: %s\n", err) + } + }, + } + dotCommandHandlers = map[string]func([]string) string{ + "roll": func(s []string) string { + if len(s) == 0 { + return "Error rolling dice: no expression provided" + } + if len(s) > 1 { + return "Error rolling dice: dice expression should have no spaces" + } + opts := optionMap{ + "dice": &discordgo.ApplicationCommandInteractionDataOption{ + Type: discordgo.ApplicationCommandOptionString, + Value: s[0], + }, + } + return roll(opts) + }, + } +) + +func roll(opts optionMap) string { + var exp string + if v, ok := opts["dice"]; ok { + exp = v.StringValue() + } else { + return "Error rolling dice: no expression provided" + } + d, err := dice.CreateFromExp(exp) + if err != nil { + return "Error rolling dice: " + err.Error() + } + roll, err := d.Roll() + if err != nil { + ret := fmt.Sprintf("Error rolling dice: %s", err) + fmt.Println(ret) + return ret + } + var ret strings.Builder + _, err = fmt.Fprint(&ret, "Rolled dice: `") + if err != nil { + return fmt.Sprintf("Error rolling dice: %s", err) + } + total := 0 + for i, die := range roll { + _, err = fmt.Fprintf(&ret, "%v", die.Result) + if err != nil { + return fmt.Sprintf("Error rolling dice: %s", err) + } + if i < len(roll)-1 { + fmt.Fprint(&ret, ", ") + } + total += die.Result + } + _, err = fmt.Fprint(&ret, "`") + if err != nil { + return fmt.Sprintf("Error rolling dice: %s", err) + } + _, err = fmt.Fprintf(&ret, "\nTotal: %v", total) + if err != nil { + return fmt.Sprintf("Error rolling dice: %s", err) + } + if ret.Len() < 2000 { + return ret.String() + } + return fmt.Sprintf("Too many dice to display. Omitting.\nTotal: %v", total) +} diff --git a/dice/dice.go b/dice/dice.go new file mode 100644 index 0000000..c6cb618 --- /dev/null +++ b/dice/dice.go @@ -0,0 +1,175 @@ +package dice + +import ( + "errors" + "fmt" + "math/rand/v2" + "regexp" + "strconv" +) + +type DieExp struct { + Size int + Count int + Add int + KeepHigh int + KeepLow int + Explode bool +} + +type DieResult struct { + Size int + Result int +} + +func (d DieExp) Roll() ([]DieResult, error) { + results := make([]DieResult, 0) + var err error + + for i := 0; i < d.Count; i++ { + results, err = addDice(d, results) + if err != nil { + return nil, err + } + } + + if d.KeepHigh != 0 { + highResults := make([]DieResult, 0) + for i := 0; i < d.KeepHigh; i++ { + var high int + for j, val := range results { + if results[high].Result > val.Result { + fmt.Printf("Result %v higher than %v\n", results[high].Result, results[j].Result) + high = j + } else { + fmt.Printf("Result %v lower than %v\n", results[high].Result, results[j].Result) + } + } + highResults = append(highResults, results[high]) + results = append(results[:high], results[high+1:]...) + } + results = highResults + } + + if d.KeepLow > len(results) { + return results, errors.New("keep low higher than remaining results") + } + + if d.KeepLow != 0 { + lowResults := make([]DieResult, 0) + for i := 0; i < d.KeepLow; i++ { + var low int + for j, val := range results { + if results[low].Result < val.Result { + fmt.Printf("Result %v lower than %v\n", results[low].Result, results[j].Result) + low = j + } else { + fmt.Printf("Result %v higher than %v\n", results[low].Result, results[j].Result) + } + } + lowResults = append(lowResults, results[low]) + results = append(results[:low], results[low+1:]...) + } + results = lowResults + } + + if d.Explode { + var neededExplosions int + for _, result := range results { + if result.Result == result.Size { + neededExplosions++ + } + } + for explosions := 0; explosions < neededExplosions; explosions++ { + alreadyRolled := len(results) + results, err = addDice(d, results) + if err != nil { + return nil, err + } + for _, result := range results[alreadyRolled:] { + if result.Result == result.Size { + neededExplosions++ + } + } + } + } + return results, nil +} + +func CreateFromExp(exp string) (DieExp, error) { + d := DieExp{ + Count: 1, + KeepHigh: 0, + KeepLow: 0, + Explode: false, + } + match := regexp.MustCompile("[0-9]*d[0-9]+([hl][0-9]+)*e?([+-][0-9]+)?") + countMatch := regexp.MustCompile("^[0-9]*") + sizeMatch := regexp.MustCompile("d[0-9]+") + highMatch := regexp.MustCompile("h[0-9]+") + lowMatch := regexp.MustCompile("l[0-9]+") + explodeMatch := regexp.MustCompile("e") + addMatch := regexp.MustCompile("[+\\-][0-9]+") + match.Longest() + countMatch.Longest() + sizeMatch.Longest() + highMatch.Longest() + lowMatch.Longest() + matched := match.FindString(exp) + if len(matched) != len(exp) { + return DieExp{}, errors.New("dice expression invalid") + } + + countString := countMatch.FindString(exp) + if len(countString) != 0 { + count, _ := strconv.Atoi(countString) + d.Count = count + } + + sizeString := sizeMatch.FindString(exp) + if len(sizeString) != 0 { + size, _ := strconv.Atoi(sizeString[1:]) + d.Size = size + } + + highString := highMatch.FindString(exp) + if len(highString) != 0 { + high, _ := strconv.Atoi(highString[1:]) + d.KeepHigh = high + } + + lowString := lowMatch.FindString(exp) + if len(lowString) != 0 { + low, _ := strconv.Atoi(lowString[1:]) + d.KeepLow = low + } + + if explodeMatch.MatchString(exp) { + d.Explode = true + } + + addString := addMatch.FindString(exp) + if len(addString) != 0 { + add, _ := strconv.Atoi(addString[1:]) + if addString[0] == '-' { + add = 0 - add + } + d.Add = add + } + + return d, nil +} + +func addDice(d DieExp, results []DieResult) ([]DieResult, error) { + if d.Size < 2 { + return nil, errors.New("dice size is too small. Should be at least 2") + } + if len(results) > 1000 { + return nil, errors.New("too many dice rolled. This may be caused by a dice explosion that got out of hand") + } + result := DieResult{ + Size: d.Size, + Result: rand.IntN(d.Size) + 1, + } + return append(results, result), nil +} diff --git a/dice/dice_test.go b/dice/dice_test.go new file mode 100644 index 0000000..040ccd7 --- /dev/null +++ b/dice/dice_test.go @@ -0,0 +1,256 @@ +package dice + +import ( + "reflect" + "testing" +) + +func TestDieExp_roll(t *testing.T) { + type fields struct { + Size int + Count int + KeepHigh int + KeepLow int + Explode bool + } + type results struct { + min int + max int + } + tests := []struct { + name string + fields fields + want results + wantErr bool + }{ + { + name: "simple roll", + fields: fields{ + Size: 6, + Count: 1, + KeepHigh: 0, + KeepLow: 0, + Explode: false, + }, + want: results{ + min: 1, + max: 6, + }, + wantErr: false, + }, + { + name: "explode d1", + fields: fields{ + Size: 1, + Count: 1, + KeepHigh: 0, + KeepLow: 0, + Explode: true, + }, + want: results{ + min: 1, + max: 1, + }, + wantErr: true, + }, + { + name: "larger roll", + fields: fields{ + Size: 1000, + Count: 5, + KeepHigh: 0, + KeepLow: 0, + Explode: false, + }, + want: results{ + min: 5, + max: 5000, + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + d := DieExp{ + Size: tt.fields.Size, + Count: tt.fields.Count, + KeepHigh: tt.fields.KeepHigh, + KeepLow: tt.fields.KeepLow, + Explode: tt.fields.Explode, + } + got, err := d.Roll() + total := 0 + for _, v := range got { + total += v.Result + } + if (err != nil) != tt.wantErr { + t.Errorf("roll() error = %v, wantErr %v", err, tt.wantErr) + return + } + if ((total < tt.want.min) || (total > tt.want.max)) && (!tt.wantErr) { + t.Errorf("roll() got = %v, want between %v and %v", total, tt.want.min, tt.want.max) + } + }) + } +} + +func TestDieExp_roll_keep_high(t *testing.T) { + d := DieExp{ + Size: 20, + Count: 10, + KeepHigh: 2, + KeepLow: 0, + Explode: false, + } + got, err := d.Roll() + if err != nil { + t.Errorf("roll() error = %v", err) + return + } + if len(got) != 2 { + t.Errorf("roll() got = %v, want length 2", got) + } +} + +func TestDieExp_roll_keep_low(t *testing.T) { + d := DieExp{ + Size: 20, + Count: 10, + KeepHigh: 0, + KeepLow: 2, + Explode: false, + } + got, err := d.Roll() + if err != nil { + t.Errorf("roll() error = %v", err) + return + } + if len(got) != 2 { + t.Errorf("roll() got = %v, want length 2", got) + } +} + +func TestDieExp_roll_keep_high_low(t *testing.T) { + d := DieExp{ + Size: 20, + Count: 10, + KeepHigh: 5, + KeepLow: 2, + Explode: false, + } + got, err := d.Roll() + if err != nil { + t.Errorf("roll() error = %v", err) + return + } + if len(got) != 2 { + t.Errorf("roll() got = %v, want length 2", got) + } +} + +func TestDieExp_roll_keep_low_too_high(t *testing.T) { + d := DieExp{ + Size: 20, + Count: 2, + KeepHigh: 0, + KeepLow: 5, + Explode: false, + } + _, err := d.Roll() + if err == nil { + t.Errorf("expected roll error, got nil") + } +} + +func TestCreateFromExp(t *testing.T) { + type args struct { + exp string + } + tests := []struct { + name string + args args + want DieExp + wantErr bool + }{ + { + name: "test 1", + args: args{ + exp: "1d20", + }, + want: DieExp{ + Size: 20, + Count: 1, + KeepHigh: 0, + KeepLow: 0, + Explode: false, + Add: 0, + }, + wantErr: false, + }, + { + name: "test 2", + args: args{ + exp: "d20", + }, + want: DieExp{ + Size: 20, + Count: 1, + KeepHigh: 0, + KeepLow: 0, + Explode: false, + Add: 0, + }, + wantErr: false, + }, + { + name: "test 3", + args: args{ + exp: "3d20h2l1e+1", + }, + want: DieExp{ + Size: 20, + Count: 3, + KeepHigh: 2, + KeepLow: 1, + Explode: true, + Add: 1, + }, + wantErr: false, + }, + { + name: "test 4", + args: args{ + exp: "3d20h2l1e+1t", + }, + want: DieExp{}, + wantErr: true, + }, + { + name: "test 5", + args: args{ + exp: "3d20h2l1e-20", + }, + want: DieExp{ + Size: 20, + Count: 3, + KeepHigh: 2, + KeepLow: 1, + Explode: true, + Add: -20, + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := CreateFromExp(tt.args.exp) + if (err != nil) != tt.wantErr { + t.Errorf("CreateFromExp() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("CreateFromExp() got = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b43a5fc --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module treerazer + +go 1.23.0 + +require ( + github.com/bwmarrin/discordgo v0.28.1 // indirect + github.com/gorilla/websocket v1.4.2 // indirect + golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b // indirect + golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e5a04a3 --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +github.com/bwmarrin/discordgo v0.28.1 h1:gXsuo2GBO7NbR6uqmrrBDplPUx2T3nzu775q/Rd1aG4= +github.com/bwmarrin/discordgo v0.28.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= +github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b h1:7mWr3k41Qtv8XlltBkDkl8LoP3mpSgBW8BUoxtEdbXg= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=