This commit is contained in:
Malachy Byrne 2025-03-30 16:26:48 +01:00
commit 4b8ae1f252
Signed by: malmal200
GPG Key ID: EC21443030A655D9
6 changed files with 662 additions and 0 deletions

105
application/bot.go Normal file
View File

@ -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)
}
}
}

104
application/commands.go Normal file
View File

@ -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)
}

175
dice/dice.go Normal file
View File

@ -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
}

256
dice/dice_test.go Normal file
View File

@ -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)
}
})
}
}

10
go.mod Normal file
View File

@ -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
)

12
go.sum Normal file
View File

@ -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=