diff --git a/.gitignore b/.gitignore index 9bc3a8f..90253eb 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,8 @@ cake4everybot # configuration files for tokens, access keys and other connection data *env.yaml + +# runtime config files (as described in config.yml) +modules/secretsanta/players.json +twitch/prizes.json +twitch/times.json diff --git a/config.yaml b/config.yaml index 428b9d2..f0b7cb4 100644 --- a/config.yaml +++ b/config.yaml @@ -34,6 +34,10 @@ event: adventcalendar: images: data/images/adventcalendar + secretsanta: + # the filepath for the players + players: modules/secretsanta/players.json + twitch_giveaway: # The amount of points a single giveaway ticket costs. ticket_cost: 1000 diff --git a/data/lang/de.yaml b/data/lang/de.yaml index b88edd4..6569719 100644 --- a/data/lang/de.yaml +++ b/data/lang/de.yaml @@ -131,6 +131,7 @@ discord.command: msg.setup.no_reactions: Diese Nachricht hat keine Reaktionen. Nur Leute, die mit %s reagiert haben, werden eingeschlossen. msg.setup.not_enough_reactions: Nicht genug Reaktionen um zu starten. Es werden mindestens %d Reaktionen benötigt. + msg.setup.users: Teilnehmer module: adventcalendar: diff --git a/data/lang/en.yaml b/data/lang/en.yaml index d6e5ea9..5d1eea2 100644 --- a/data/lang/en.yaml +++ b/data/lang/en.yaml @@ -131,6 +131,7 @@ discord.command: msg.setup.no_reactions: This message doesn't have any vote reactions. Only members who reated with %s are included. msg.setup.not_enough_reactions: Not enough votes to start a game. At least %d votes are required. + msg.setup.users: Members module: adventcalendar: diff --git a/modules/secretsanta/handlerMessageSetup.go b/modules/secretsanta/handlerMessageSetup.go index 9bd6f06..a48264a 100644 --- a/modules/secretsanta/handlerMessageSetup.go +++ b/modules/secretsanta/handlerMessageSetup.go @@ -3,7 +3,9 @@ package secretsanta import ( "cake4everybot/data/lang" "cake4everybot/util" + "fmt" + "github.com/bwmarrin/discordgo" ) func (cmd MsgCmd) handler() { @@ -43,4 +45,40 @@ func (cmd MsgCmd) handler() { cmd.ReplyHiddenf(lang.GetDefault(tp+"msg.setup.not_enough_reactions"), 2) return } + + e := &discordgo.MessageEmbed{ + Title: lang.GetDefault(tp + "title"), + Color: 0x690042, + } + + var ( + names string + players []*player = make([]*player, 0, len(users)) + ) + for _, u := range users { + member, err := cmd.Session.GuildMember(cmd.Interaction.GuildID, u.ID) + if member == nil { + log.Printf("WARN: Could not get member '%s' from guild '%s': %v", u.ID, cmd.Interaction.GuildID, err) + continue + } + players = append(players, &player{Member: member}) + names += fmt.Sprintf("%s\n", member.Mention()) + } + if len(players) < 2 { + cmd.ReplyHiddenf(lang.GetDefault(tp+"msg.setup.not_enough_reactions"), 2) + return + } + util.AddEmbedField(e, lang.GetDefault(tp+"msg.setup.users"), names, false) + + players = DerangementMatch(players) + + err = cmd.setPlayers(players) + if err != nil { + log.Printf("Error on set players: %v\n", err) + cmd.ReplyError() + return + } + + util.SetEmbedFooter(cmd.Session, tp+"display", e) + cmd.ReplyHiddenEmbed(e) } diff --git a/modules/secretsanta/secretsantabase.go b/modules/secretsanta/secretsantabase.go index 9c8e83f..4124d57 100644 --- a/modules/secretsanta/secretsantabase.go +++ b/modules/secretsanta/secretsantabase.go @@ -2,9 +2,14 @@ package secretsanta import ( "cake4everybot/util" + "encoding/json" + "fmt" logger "log" + "os" "github.com/bwmarrin/discordgo" + "github.com/spf13/viper" + "golang.org/x/exp/rand" ) const ( @@ -20,3 +25,150 @@ type secretSantaBase struct { member *discordgo.Member user *discordgo.User } + +// getPlayers returns the list of players for the current guild. If it is the first time, it loads +// the players from the file or creates an empty file. +func (ssb secretSantaBase) getPlayers() ([]*player, error) { + if allPlayers != nil { + return allPlayers[ssb.Interaction.GuildID], nil + } + + log.Println("First time getting players. Loading from file...") + playersPath := viper.GetString("event.secretsanta.players") + playersData, err := os.ReadFile(playersPath) + if err != nil { + if !os.IsNotExist(err) { + return nil, fmt.Errorf("read players file: %v", err) + } + allPlayers = make(AllPlayers) + playersData, err = json.Marshal(allPlayers) + if err != nil { + return nil, fmt.Errorf("marshal players file: %v", err) + } + err = os.WriteFile(playersPath, playersData, 0644) + if err != nil { + return nil, fmt.Errorf("write players file: %v", err) + } + log.Printf("Created players file: %s\n", playersPath) + return []*player{}, nil + } + allPlayersUnresolved := AllPlayersUnresolved{} + err = json.Unmarshal(playersData, &allPlayersUnresolved) + if err != nil { + return nil, fmt.Errorf("unmarshal players file: %v", err) + } + err = allPlayersUnresolved.Resolve(ssb.Session) + if err != nil { + return nil, fmt.Errorf("resolve players file: %v", err) + } + log.Printf("Got %d guilds from file", len(allPlayers)) + + return allPlayers[ssb.Interaction.GuildID], nil +} + +// setPlayers sets the players for the current guild. +func (ssb secretSantaBase) setPlayers(players []*player) (err error) { + if _, err = ssb.getPlayers(); err != nil { + return err + } + + allPlayers[ssb.Interaction.GuildID] = players + playersData, err := json.Marshal(allPlayers) + if err != nil { + return fmt.Errorf("marshal players file: %v", err) + } + err = os.WriteFile(viper.GetString("event.secretsanta.players"), playersData, 0644) + if err != nil { + return fmt.Errorf("write players file: %v", err) + } + return nil +} + +// player is a player in the secret santa game +type player struct { + *discordgo.Member + + // Match is the matched player + Match *player + // Address is the address of the player + Address string +} + +type playerUnresolved struct { + ID string `json:"id"` + MatchID string `json:"match"` + Address string `json:"address"` +} + +// AllPlayers is a map from guild ID to a list of players +type AllPlayers map[string][]*player + +// allPlayers is the current state of all players. +// See [AllPlayers] +var allPlayers AllPlayers + +// MarshalJSON implements json.Marshaler +func (allPlayers AllPlayers) MarshalJSON() ([]byte, error) { + m := make(AllPlayersUnresolved) + for guildID, players := range allPlayers { + for _, player := range players { + m[guildID] = append(m[guildID], &playerUnresolved{ + ID: player.User.ID, + MatchID: player.Match.User.ID, + Address: player.Address, + }) + } + + } + return json.Marshal(m) +} + +// AllPlayersUnresolved is a map from guild ID to a list of unresolved players. +// Unresolved players have no member but only an ID +type AllPlayersUnresolved map[string][]*playerUnresolved + +// Resolve resolves allPlayersUnresolved into allPlayers +func (allPlayersUnresolved AllPlayersUnresolved) Resolve(s *discordgo.Session) (err error) { + allPlayers = make(AllPlayers) + for guildID, unresolvedPlayers := range allPlayersUnresolved { + resolvedPlayers := map[string]*player{} + for _, up := range unresolvedPlayers { + member, err := s.GuildMember(guildID, up.ID) + if err != nil { + return fmt.Errorf("failed to get guild member %s/%s: %v", guildID, up.ID, err) + } + resolvedPlayers[up.ID] = &player{ + Member: member, + Match: resolvedPlayers[up.MatchID], + Address: up.Address, + } + } + for _, rp := range resolvedPlayers { + if rp.Match != nil { + continue + } + rp.Match = resolvedPlayers[rp.User.ID] + + allPlayers[guildID] = append(allPlayers[guildID], rp) + } + } + return nil +} + +// DerangementMatch matches the players in a way that no one gets matched to themselves. +func DerangementMatch(players []*player) []*player { + n := len(players) + players2 := make([]*player, n) + copy(players2, players) + + for i := 0; i < n-1; i++ { + j := i + rand.Intn(n-i-1) + 1 + players2[i], players2[j] = players2[j], players2[i] + } + + for i, item := range players { + item.Match = players2[i] + } + + return players +}