commit 6bce4375dacfe773b0eea30d4be1dbe96f5548cd Author: Hamcha Date: Mon Aug 26 11:20:00 2019 +0200 Move bot to its own repo diff --git a/bot/bot.go b/bot/bot.go new file mode 100644 index 0000000..a64f6b8 --- /dev/null +++ b/bot/bot.go @@ -0,0 +1,37 @@ +package bot + +import ( + "math/rand" + + "git.fromouter.space/mcg/draft" +) + +// Bot implements a bot for filling spots in draft pods +type Bot struct { + player *draft.Player + + // Currently unused, the bot just picks at random. + // In the future, these fields will be used for having the bot try to assemble + // a sort-of playable deck. The actual deck doesn't matter, just the fact + // that their picks make some sort of sense. + color1 string + color2 string + entryCount int + friendCount int + otherCount int +} + +// MakeBot returns a bot for a given pod spot +func MakeBot(player *draft.Player) *Bot { + bot := &Bot{ + player: player, + } + return bot +} + +// PickNext makes the bot pick a card from his pack +func (b *Bot) PickNext() { + // For now, just pick a card at random + cardid := rand.Intn(len(b.player.CurrentPack)) + b.player.Pick(b.player.CurrentPack[cardid]) +} diff --git a/bot/bot_test.go b/bot/bot_test.go new file mode 100644 index 0000000..016c097 --- /dev/null +++ b/bot/bot_test.go @@ -0,0 +1,66 @@ +package bot_test + +import ( + "testing" + + "git.fromouter.space/mcg/mlp-server-tools/draftbot/bot" + + "git.fromouter.space/mcg/draft" +) + +const PACKSIZE = 5 + +// Test set that can be used by tests that don't need special features (like alternates) +var testSet = &draft.GenericSet{ + Cards: []draft.Card{{ID: "a"}, {ID: "b"}, {ID: "c"}}, + PackSize: PACKSIZE, +} + +func TestPick(t *testing.T) { + const PacksPerPlayer = 3 + const PlayersPerPod = 5 + + // Get provider for test set + testProvider := draft.PacksFromSet(PacksPerPlayer, testSet) + + // Create pod + pod := draft.MakePod(PlayersPerPod, testProvider) + + // Create a bot for each player in the pod + var bots []*bot.Bot + for _, player := range pod.Players { + bots = append(bots, bot.MakeBot(player)) + } + + // Simulate a round of drafting + // Repeat until all packs are gone + // Channels are tested when they should trigger + for packnum := 0; packnum < PacksPerPlayer; packnum++ { + + // Open new packs! + err := pod.OpenPacks() + if err != nil { + t.Fatalf("Got an error while opening packs #%d: %s", packnum, err.Error()) + } + + for picknum := 0; picknum < PACKSIZE; picknum++ { + for _, bot := range bots { + bot.PickNext() + } + + // Make sure either ReadyNextPick or ReadyNextPack triggers + select { + case <-pod.ReadyNextPick: + // Pass packs around + err := pod.NextPacks() + if err != nil { + t.Fatalf("Got an error while passing packs: %s", err.Error()) + } + case <-pod.ReadyNextPack: + break + default: + t.Fatal("Either ReadyNextPick/ReadyNextPack should trigger but neither has") + } + } + } +} diff --git a/cmd/cgdraftbot/main.go b/cmd/cgdraftbot/main.go new file mode 100644 index 0000000..f0279dc --- /dev/null +++ b/cmd/cgdraftbot/main.go @@ -0,0 +1,97 @@ +package main // import "git.fromouter.space/mcg/mlp-server-tools/draftbot" + +import ( + "errors" + "flag" + "fmt" + "math/rand" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "git.fromouter.space/Artificiale/moa/sd" + botapi "git.fromouter.space/mcg/cardgage/client/bot" + "git.fromouter.space/mcg/draft/mlp" + bot "git.fromouter.space/mcg/mlp-server-tools/draftbot" + "github.com/go-kit/kit/log" +) + +var logger log.Logger +var logAll bool + +func main() { + consulAddr := flag.String("consul.addr", "consul:8500", "Consul address") + botName := flag.String("bot.name", "draftbot", "Bot name") + gameFilter := flag.String("filter.game", "mlpccg-mcg", "What game to filter for (separated by comma)") + tagFilter := flag.String("filter.tag", "draft", "What tags to filter for (separated by comma)") + flag.BoolVar(&logAll, "debug.log", false, "Log a lot of stuff") + flag.Parse() + + logger = log.NewLogfmtLogger(os.Stderr) + logger = log.With(logger, "ts", log.DefaultTimestampUTC) + logger = log.With(logger, "caller", log.DefaultCaller) + + // Register with consul + registrar := sd.Register(*consulAddr, sd.Options{ + Name: "draftbot", + Tags: []string{ + "bot", // I am a room bot + }, + }, logger) + defer registrar.Deregister() + + // Seed RNG + rand.Seed(time.Now().UnixNano()) + + // Initialize consul client for service discovery + sd.InitClient(*consulAddr) + + errs := make(chan error) + go func() { + c := make(chan os.Signal) + signal.Notify(c, syscall.SIGINT, syscall.SIGTERM) + errs <- fmt.Errorf("%s", <-c) + }() + + go func() { + games := strings.Split(*gameFilter, ",") + tags := strings.Split(*tagFilter, ",") + errs <- runBot(*botName, games, tags) + }() + + logger.Log("exit", <-errs) +} + +func runBot(name string, games, tags []string) error { + // Search for roomsvc + roomsvc, err := sd.GetOne("roomsvc") + if err != nil { + return err + } + + // Load all sets into memory + err = mlp.LoadAllSets() + if err != nil { + return err + } + + addr := fmt.Sprintf("ws://%s:%d/bot", roomsvc.Service.Address, roomsvc.Service.Port) + + wsbot, err := botapi.NewBot(addr, botapi.Params{ + Name: name, + MsgBufferSize: 10, + Logger: logger, + GameIDs: games, + Tags: tags, + }) + if err != nil { + return err + } + + draftbot := bot.NewDraftBot(wsbot, name) + wsbot.Listen(draftbot.OnMessage) + + return errors.New("eof") +} diff --git a/cube.go b/cube.go new file mode 100644 index 0000000..d08793b --- /dev/null +++ b/cube.go @@ -0,0 +1,81 @@ +package draftbot + +import ( + "encoding/json" + "io/ioutil" + "net/http" + "strings" + + "git.fromouter.space/mcg/draft" + "git.fromouter.space/mcg/draft/mlp" +) + +func loadCube(cubeURL string) ([]draft.Card, error) { + // Fetch document + resp, err := http.Get(cubeURL) + if err != nil { + return nil, err + } + + respBytes, err := ioutil.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return nil, err + } + + // Each line is a card ID + cardids := strings.Split(string(respBytes), "\n") + cards := make([]draft.Card, 0) + // Convert to draft.Card + for _, cardid := range cardids { + cardid := strings.TrimSpace(cardid) + if len(cardid) < 1 { + // Skip empty lines + continue + } + cards = append(cards, draft.Card{ID: cardid}) + } + return cards, nil +} + +// I8PCubeConfig is an external JSON doc with the I8PCube pack seeding schema and card list +type I8PCubeConfig struct { + Schema mlp.I8PSchema + ProblemPackSize int + Cards map[mlp.I8PType][]string +} + +// ToCube creates a I8PCube from the config +func (cfg *I8PCubeConfig) ToCube() (*mlp.I8PCube, error) { + // Load cards from given list + var err error + pool := make(mlp.I8PPool) + for typ, cardids := range cfg.Cards { + pool[typ], err = mlp.LoadCardList(cardids, true) + if err != nil { + return nil, err + } + } + + // Make cube and return it + return mlp.MakeI8PCube(pool, cfg.Schema, cfg.ProblemPackSize), nil +} + +func loadI8PCube(cubeURL string) (*mlp.I8PCube, error) { + // Fetch document + resp, err := http.Get(cubeURL) + if err != nil { + return nil, err + } + + // Deserialize response to JSON object + var cubeconf I8PCubeConfig + err = json.NewDecoder(resp.Body).Decode(&cubeconf) + resp.Body.Close() + if err != nil { + return nil, err + } + + // Create cube from config + return cubeconf.ToCube() +} diff --git a/draftbot.go b/draftbot.go new file mode 100644 index 0000000..5084f51 --- /dev/null +++ b/draftbot.go @@ -0,0 +1,152 @@ +package draftbot + +import ( + room "git.fromouter.space/mcg/cardgage/room/api" + "github.com/go-kit/kit/log" +) + +// DraftBot is the functional part of draftbot +type DraftBot struct { + API BotInterface + Logger log.Logger + Name string + Rooms map[string]roomInfo + Sessions map[string]*session +} + +type roomInfo struct { + Name string + Owner string +} + +// BotInterface is the interface needed by draftbot for working in cardgage +// This exists so that draftbot can be attached to a mocked API for testing +type BotInterface interface { + Send(room.BotMessage) +} + +// NewDraftBot creates a new draft bot instance with the given name +// and communication interface +func NewDraftBot(botAPI BotInterface, name string) *DraftBot { + return &DraftBot{ + API: botAPI, + Name: name, + Rooms: make(map[string]roomInfo), + Sessions: make(map[string]*session), + } +} + +// OnMessage is the function to be called when messages are received +func (d *DraftBot) OnMessage(msg room.ServerMessage) { + switch msg.Type { + case room.MsgMessage: + if d.Logger != nil { + d.Logger.Log("event", "message", + "roomid", msg.RoomID, + "from", msg.Message.From, + "to", msg.Message.To, + "content", msg.Message.Message) + } + // Only consider messages that speak directly to me + if msg.Message.To == d.Name { + d.handleMessage(msg.RoomID, *msg.Message) + } + case room.MsgEvent: + if d.Logger != nil { + d.Logger.Log("event", "event", + "roomid", msg.RoomID, + "content", msg.Event.Message) + } + d.handleEvent(msg.RoomID, *msg.Event) + } +} + +func (d *DraftBot) sendMessage(roomid string, msg room.Message) { + d.API.Send(room.BotMessage{ + RoomID: roomid, + Type: room.MsgMessage, + Message: &msg, + }) +} + +func (d *DraftBot) handleMessage(roomid string, msg room.Message) { + // Get session in room + session, ok := d.Sessions[roomid] + if !ok { + // Room does not have a currently running session, ignore unless it's the owner asking for specific stuff + d.commands(commandMap{ + // Owner wants to create a session + "create": d.cmdfnOnlyOwner(d.cmdCreateSession), + })(roomid, msg) + return + } + + // Check if player is in the draft + _, ok = session.Players[msg.From] + if !ok { + // Player not in draft, are they asking to join? + d.commands(commandMap{ + // Player wants to join the session + "join": d.cmdJoinSession, + // Owner wants to start the session (but not partecipate) + "start": d.cmdfnOnlyOwner(d.cmdStartSession), + })(roomid, msg) + return + } + + // Players in the draft session + d.commands(commandMap{ + // Player has picked a card + "pick": d.cmdPickCard, + // Owner wants to start the session + "start": d.cmdfnOnlyOwner(d.cmdStartSession), + })(roomid, msg) +} + +func (d *DraftBot) handleEvent(roomid string, evt room.Event) { + switch evt.Type { + + // Got added to a new room + case room.EvtNewRoom: + // Add room to the list + d.Rooms[evt.Room.Id] = roomInfo{ + Name: evt.Room.Name, + Owner: evt.Room.Creator, + } + + // Someone left + case room.EvtLeft: + // Check if room has a running session + sess, ok := d.Sessions[roomid] + if !ok { + break + } + // Check if player is in that session + player, ok := sess.Players[evt.PlayerName] + if !ok { + break + } + // Replace player with bot + //TODO + _ = player + + // A room got closed + case room.EvtRoomClosed: + // Check if there's a session there + session, ok := d.Sessions[roomid] + if ok { + // Close session + session.exit <- true + // Remove session from list + delete(d.Sessions, roomid) + } + // Remove room from list + delete(d.Rooms, roomid) + } +} + +func (d *DraftBot) handleSessionMessages(roomid string, s *session) { + for msg := range s.messages { + d.sendMessage(roomid, msg) + } +} diff --git a/draftbot.messages.go b/draftbot.messages.go new file mode 100644 index 0000000..6575468 --- /dev/null +++ b/draftbot.messages.go @@ -0,0 +1,190 @@ +package draftbot + +import ( + "fmt" + + "github.com/mitchellh/mapstructure" + + room "git.fromouter.space/mcg/cardgage/room/api" + "git.fromouter.space/mcg/draft" +) + +type commandHandler func(roomid string, msg room.Message) +type commandMap map[string]commandHandler + +func (d *DraftBot) commands(commands commandMap) commandHandler { + return func(roomid string, msg room.Message) { + action, ok := commands[msg.Type] + if !ok { + cmdlist := []string{} + for cmd := range commands { + cmdlist = append(cmdlist, cmd) + } + d.sendMessage(roomid, room.Message{ + To: msg.From, + Type: "command-unavailable", + Message: fmt.Sprintf("Available commands (at this state) are: %s", cmdlist), + }) + return + } + action(roomid, msg) + } +} + +func (d *DraftBot) cmdJoinSession(roomid string, msg room.Message) { + // Get session + session, _ := d.Sessions[roomid] + + // Players can only join if session didn't start yet + if session.started { + d.sendMessage(roomid, room.Message{ + To: msg.From, + Type: "session-already-started", + Message: "You can't join a running session", + }) + return + } + + // Check if there are still open slots + if len(session.Players)+1 > len(session.Pod.Players) { + d.sendMessage(roomid, room.Message{ + To: msg.From, + Type: "session-full", + Message: "There aren't any spots left", + }) + return + } + + // Add player to the list + session.Players[msg.From] = nil + + d.sendMessage(roomid, room.Message{ + Channel: "draft", + Type: "player-joined-session", + Data: msg.From, + Message: fmt.Sprintf("%s joined the draft session (%d players, %d missing)", msg.From, len(session.Players), len(session.Pod.Players)-len(session.Players)), + }) +} + +func (d *DraftBot) cmdPickCard(roomid string, msg room.Message) { + // Get session + session, _ := d.Sessions[roomid] + + // Get player + player, _ := session.Players[msg.From] + + // Get picked card + picked := msg.Data.(string) + + // Try to pick on player struct + err := player.Pick(draft.Card{ID: picked}) + if err != nil { + if err == draft.ErrNotInPack { + d.sendMessage(roomid, room.Message{ + To: msg.From, + Type: "invalid-pick", + }) + } else { + // Technically not needed, as Pick can only throw ErrNotInPack right now + } + return + } + d.sendMessage(roomid, room.Message{ + Channel: "draft", + Type: "card-picked", + Data: struct { + Player string + }{ + msg.From, + }, + Message: fmt.Sprintf("%s picked a card from his pack", msg.From), + }) +} + +func (d *DraftBot) cmdCreateSession(roomid string, msg room.Message) { + // Get session options from data + var opt SessionOptions + err := mapstructure.Decode(msg.Data, &opt) + if err != nil { + d.sendMessage(roomid, room.Message{ + To: msg.From, + Type: "invalid-data", + Message: "Error parsing session options: " + err.Error(), + }) + return + } + + sess, err := newSession(opt.Players, opt.Options) + if err != nil { + d.sendMessage(roomid, room.Message{ + To: msg.From, + Type: "session-create-error", + Data: err.Error(), + Message: "Error creating session: " + err.Error(), + }) + return + } + + // All ok, assign session + d.Sessions[roomid] = sess + + // Start handling messages for the session + go d.handleSessionMessages(roomid, sess) + + // Tell everyone about the new session + d.sendMessage(roomid, room.Message{ + Channel: "draft", + Type: "session-open", + Data: opt, + Message: fmt.Sprintf("Created a new draft session for %d players, type: %s", opt.Players, opt.Options.Type), + }) +} + +func (d *DraftBot) cmdStartSession(roomid string, msg room.Message) { + // Get session + session, _ := d.Sessions[roomid] + + // Try starting the session + err := session.Start() + if err != nil { + d.sendMessage(roomid, room.Message{ + To: msg.From, + Type: "session-start-error", + Data: err.Error(), + Message: "Could not start session: " + err.Error(), + }) + return + } + + // Tell everyone about the new session + d.sendMessage(roomid, room.Message{ + Channel: "draft", + Type: "session-start", + Message: "Session started, get drafting!", + }) +} + +func (d *DraftBot) cmdfnOnlyOwner(wrapped commandHandler) commandHandler { + return func(roomid string, msg room.Message) { + // Get room the message was sent from + roomData, ok := d.Rooms[roomid] + if !ok { + // Message from a room we're not in? + // Ignore it for now + return + } + + // Make sure the message is coming from the room owner + if msg.From != roomData.Owner { + d.sendMessage(roomid, room.Message{ + To: msg.From, + Type: "must-be-owner", + Message: "Sorry, only the room's owner can tell me to do that", + }) + return + } + + // Check done, call wrapped function + wrapped(roomid, msg) + } +} diff --git a/draftbot_test.go b/draftbot_test.go new file mode 100644 index 0000000..3962e80 --- /dev/null +++ b/draftbot_test.go @@ -0,0 +1,433 @@ +package draftbot_test + +import ( + "fmt" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "git.fromouter.space/mcg/draft" + "git.fromouter.space/mcg/draft/mlp" + mlptestdata "git.fromouter.space/mcg/draft/mlp/testdata" + + draftbot "git.fromouter.space/mcg/mlp-server-tools/draftbot" + testdata "git.fromouter.space/mcg/mlp-server-tools/draftbot/testdata" + + "git.fromouter.space/mcg/cardgage/client/bot" + lobby "git.fromouter.space/mcg/cardgage/lobby/proto" + room "git.fromouter.space/mcg/cardgage/room/api" +) + +const TestBotName = "test-bot" +const TestRoomName = "test-room" + +type MockServer struct { + in chan room.ServerMessage + out chan room.BotMessage + t *testing.T + timeout time.Duration + roomName string +} + +func makeMockServer(t *testing.T, timeout int) *MockServer { + srv := &MockServer{ + in: make(chan room.ServerMessage, 99), + out: make(chan room.BotMessage, 99), + t: t, + timeout: time.Duration(timeout) * time.Second, + roomName: TestRoomName, + } + return srv +} + +func (m *MockServer) Send(msg room.BotMessage) { + m.out <- msg +} + +func (m *MockServer) Bind(fn bot.MessageHandler) { + for msg := range m.in { + fn(msg) + } +} + +func TestDraftSession(t *testing.T) { + mock := makeMockServer(t, 5) + drafter := draftbot.NewDraftBot(mock, TestBotName) + go mock.Bind(drafter.OnMessage) + + // Create a new room + mock.in <- room.ServerMessage{ + RoomID: TestRoomName, + Type: room.MsgEvent, + Event: &room.Event{ + Type: room.EvtNewRoom, + Room: &lobby.Room{ + Id: TestRoomName, + Name: "Test draft room", + Creator: "test-owner", + }, + }, + } + + // Create new session with a fake message from owner + mock.message("test-owner", "create", draftbot.SessionOptions{ + Players: 8, // Two players, six bots + Options: draftbot.DraftOptions{ + Type: draftbot.DraftSet, + Positioning: draftbot.PosEven, + Set: mlp.SetAbsoluteDiscord, + PackCount: 4, + }, + }) + + mock.expect("session-open") + + // Join session as owner + mock.message("test-owner", "join", nil) + mock.expect("player-joined-session") + + // .. and as second player + mock.message("test-guest", "join", nil) + mock.expect("player-joined-session") + + // Try to start the session + mock.message("test-owner", "start", nil) + + // These two can happen in any order, so they get their own special check + mock.multiexpect("session-start", "draft-order") + + // Pick card for each player + for packi := 0; packi < 4; packi++ { + mock.expect("draft-newpack") + for cardi := 0; cardi < 12; cardi++ { + mock.expect("draft-newpick") + // Get packs + msg1 := mock.expect("draft-availablepicks") + pack1 := msg1.Data.(draft.Pack) + msg2 := mock.expect("draft-availablepicks") + pack2 := msg2.Data.(draft.Pack) + // Pick first card in each pack + mock.message(msg1.To, "pick", pack1[0].ID) + mock.message(msg2.To, "pick", pack2[0].ID) + // Intercept picked events + mock.multiexpect("card-picked", "card-picked") + } + } + mock.expect("draft-finish") + + // Close the room + mock.in <- room.ServerMessage{ + RoomID: TestRoomName, + Type: room.MsgEvent, + Event: &room.Event{ + Type: room.EvtRoomClosed, + }, + } +} + +func TestDraftSessionButEverythingGoesWrong(t *testing.T) { + mock := makeMockServer(t, 5) + drafter := draftbot.NewDraftBot(mock, TestBotName) + go mock.Bind(drafter.OnMessage) + + // Create a new room + mock.in <- room.ServerMessage{ + Type: room.MsgEvent, + Event: &room.Event{ + Type: room.EvtNewRoom, + Room: &lobby.Room{ + Id: TestRoomName, + Name: "Test draft room", + Creator: "test-owner", + }, + }, + } + + // Try creating a new session as NOT the owner + mock.message("test-guest", "create", draftbot.SessionOptions{ + Players: 8, + Options: draftbot.DraftOptions{ + Type: draftbot.DraftSet, + Positioning: draftbot.PosEven, + Set: mlp.SetAbsoluteDiscord, + PackCount: 4, + }, + }) + mock.expect("must-be-owner") + + // Try creating a session with an invalid type + mock.message("test-owner", "create", draftbot.SessionOptions{ + Players: 8, + Options: draftbot.DraftOptions{ + Type: "lolwhat", + }, + }) + mock.expect("session-create-error") + + // Try creating a session with invalid data + mock.message("test-owner", "create", 42) + mock.expect("invalid-data") + + // Try starting a session that doesn't exist + mock.message("test-owner", "start", nil) + mock.expect("command-unavailable") + + // Try creating the session twice + mock.message("test-owner", "create", draftbot.SessionOptions{ + Players: 2, + Options: draftbot.DraftOptions{ + Type: draftbot.DraftSet, + Positioning: draftbot.PosEven, + Set: mlp.SetAbsoluteDiscord, + PackCount: 4, + }, + }) + mock.expect("session-open") + + mock.message("test-owner", "create", draftbot.SessionOptions{ + Players: 2, + Options: draftbot.DraftOptions{ + Type: draftbot.DraftSet, + Positioning: draftbot.PosEven, + Set: mlp.SetAbsoluteDiscord, + PackCount: 4, + }, + }) + mock.expect("command-unavailable") + + // Try to start session when no-one has joined + mock.message("test-owner", "start", nil) + mock.expect("session-start-error") + + // Try to make too many players join + mock.message("a", "join", nil) + mock.expect("player-joined-session") + mock.message("b", "join", nil) + mock.expect("player-joined-session") + mock.message("c", "join", nil) + mock.expect("session-full") + + // Try to make someone join a session that already started + mock.message("test-owner", "start", nil) + mock.multiexpect("session-start", "draft-order", "draft-newpack", "draft-newpick", "draft-availablepicks", "draft-availablepicks") + + mock.message("c", "join", nil) + mock.expect("session-already-started") + + //TODO More picking, etc shenanigans +} + +func TestDraftTypes(t *testing.T) { + // Load all sets into memory + err := mlp.LoadAllSets() + if err != nil { + t.Fatalf("Could not load MLP sets needed for some drafts: %s", err.Error()) + } + + mock := makeMockServer(t, 5) + drafter := draftbot.NewDraftBot(mock, TestBotName) + go mock.Bind(drafter.OnMessage) + + makeSession := func(roomName string, options draftbot.DraftOptions) { + // Create a new room + mock.in <- room.ServerMessage{ + RoomID: roomName, + Type: room.MsgEvent, + Event: &room.Event{ + Type: room.EvtNewRoom, + Room: &lobby.Room{ + Id: roomName, + Name: "Test draft room", + Creator: "test-owner", + }, + }, + } + mock.roomName = roomName + + // Create new session + mock.message("test-owner", "create", draftbot.SessionOptions{ + Players: 8, // Two players, six bots + Options: options, + }) + + mock.expect("session-open") + } + + // Make Set draft session + makeSession("set", draftbot.DraftOptions{ + Type: draftbot.DraftSet, + Positioning: draftbot.PosEven, + Set: mlp.SetAbsoluteDiscord, + PackCount: 4, + }) + + // Make Block draft session + makeSession("block", draftbot.DraftOptions{ + Type: draftbot.DraftBlock, + Positioning: draftbot.PosRandom, + Block: mlp.BlockDefenders, + }) + + // Make a plain Cube draft session + makeSession("cube", draftbot.DraftOptions{ + Type: draftbot.DraftCube, + Positioning: draftbot.PosEven, + CubeURL: mlp.HTTPSource + "cube", + PackSize: 4, + }) + + // Make a I8PCube draft session + makeSession("i8pcube", draftbot.DraftOptions{ + Type: draftbot.DraftI8PCube, + Positioning: draftbot.PosEven, + CubeURL: mlp.HTTPSource + "i8pcube", + MainCount: 1, + ProblemCount: 1, + }) +} + +func TestDraftPositioning(t *testing.T) { + mock := makeMockServer(t, 5) + drafter := draftbot.NewDraftBot(mock, TestBotName) + go mock.Bind(drafter.OnMessage) + + makeSession := func(roomName string, pos string) { + // Create a new room + mock.in <- room.ServerMessage{ + RoomID: roomName, + Type: room.MsgEvent, + Event: &room.Event{ + Type: room.EvtNewRoom, + Room: &lobby.Room{ + Id: roomName, + Name: "Test draft room", + Creator: "test-owner", + }, + }, + } + mock.roomName = roomName + + // Create new session + mock.message("test-owner", "create", draftbot.SessionOptions{ + Players: 8, // Two players, six bots + Options: draftbot.DraftOptions{ + Type: draftbot.DraftSet, + Positioning: pos, + Set: mlp.SetAbsoluteDiscord, + PackCount: 4, + }, + }) + + mock.expect("session-open") + + // Make two random players join + mock.message("a", "join", nil) + mock.expect("player-joined-session") + mock.message("b", "join", nil) + mock.expect("player-joined-session") + + // Start the session + mock.message("test-owner", "start", nil) + mock.multiexpect("session-start", "draft-order", "draft-newpack", "draft-newpick", "draft-availablepicks", "draft-availablepicks") + } + + makeSession("even", draftbot.PosEven) + makeSession("random", draftbot.PosRandom) +} + +func (m *MockServer) expect(typ string) *room.Message { + for { + select { + case msg := <-m.out: + // Skip all actions + if msg.Type != room.MsgMessage { + continue + } + + // Check expected type + if msg.Message.Type != typ { + // Oh noes + m.t.Fatalf("Expected message \"%s\" but got \"%s\" (%v)", typ, msg.Message.Type, msg.Message.Data) + } + return msg.Message + case <-time.After(m.timeout): + m.t.Fatalf("Expected message \"%s\" but found nothing (timeout)!", typ) + return nil + } + } +} + +func (m *MockServer) multiexpect(types ...string) { + for { + if len(types) < 1 { + return + } + select { + case msg := <-m.out: + + // Skip all actions + if msg.Type != room.MsgMessage { + continue + } + + m.t.Logf("-> [%s] %s", msg.Message.Type, msg.Message.Message) + + // Check expected type + found := false + for i, typ := range types { + if typ == msg.Message.Type { + found = true + types[i] = types[len(types)-1] + types = types[:len(types)-1] + break + } + } + if !found { + // Oh noes + m.t.Fatalf("Expected one of %s but got \"%s\"", types, msg.Message.Type) + } + case <-time.After(m.timeout): + m.t.Fatalf("Expected one of %s but found nothing (timeout)!", types) + } + } +} + +func (m *MockServer) message(from string, typ string, data interface{}) { + m.t.Logf("<- <%s> %s (%v)", from, typ, data) + m.in <- room.ServerMessage{ + RoomID: m.roomName, + Type: room.MsgMessage, + Message: &room.Message{ + From: from, + To: TestBotName, + Type: typ, + Data: data, + }, + } +} + +func TestMain(m *testing.M) { + testServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { + switch req.URL.Path { + case "/nopenope.json": + // 404 + http.Error(res, "Not found", http.StatusNotFound) + case "/broken.json": + // Broken response + fmt.Fprintf(res, "{{{{") + case "/pr.json": + fmt.Fprintf(res, mlptestdata.SetPremiere) + case "/cube": + fmt.Fprintf(res, testdata.TestGenericCubeFile) + case "/i8pcube": + fmt.Fprintf(res, testdata.TestI8PCubeFile) + default: + fmt.Fprintf(res, mlptestdata.SetFriendForever) + } + })) + mlp.HTTPSource = testServer.URL + "/" + defer testServer.Close() + os.Exit(m.Run()) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..dc4b043 --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module git.fromouter.space/mcg/mlp-server-tools/draftbot + +go 1.12 + +require ( + git.fromouter.space/Artificiale/moa v0.0.1-p2 + git.fromouter.space/mcg/cardgage v0.0.4 + git.fromouter.space/mcg/draft v0.0.7 + github.com/go-kit/kit v0.8.0 + github.com/mitchellh/mapstructure v1.1.2 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c4de40c --- /dev/null +++ b/go.sum @@ -0,0 +1,202 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +git.fromouter.space/Artificiale/moa v0.0.1-p2 h1:KhoRQeYCFIpHZEucrXz142O5zfSsyExyhuPSazCrt6I= +git.fromouter.space/Artificiale/moa v0.0.1-p2/go.mod h1:dHYul6vVMwDCzre18AFs6NmI22yeI7AE0iQC1jFEQi0= +git.fromouter.space/mcg/cardgage v0.0.4 h1:LHMUeNMh0QiMkM3TgsLe9l5sDmanQrej6UiWSVTb67c= +git.fromouter.space/mcg/cardgage v0.0.4/go.mod h1:vCmJ9HRdRGSWg2YQW9oNG7geYACdgWYmzL+zZdrsYhQ= +git.fromouter.space/mcg/draft v0.0.7 h1:kTcvGSs8MjrGjajKrUi0jj5uuCN6BF7x4OCvoUxQkGg= +git.fromouter.space/mcg/draft v0.0.7/go.mod h1:QQmDm9FgAZL3b2/pIDd4Eo608SxMiCQQe5vIybe/CDY= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/DataDog/datadog-go v2.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= +github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= +github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= +github.com/armon/go-metrics v0.0.0-20190430140413-ec5e00d3c878 h1:EFSB7Zo9Eg91v7MJPVsifUysc/wPdN+NOnVe6bWbdBM= +github.com/armon/go-metrics v0.0.0-20190430140413-ec5e00d3c878/go.mod h1:3AMJUQhVx52RsWOnlkpikZr01T/yAVN2gn0861vByNg= +github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/bitly/go-hostpool v0.0.0-20171023180738-a3a6125de932/go.mod h1:NOuUCSz6Q9T7+igc/hlvDOUdtWKryOrtFyIVABv/p7k= +github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= +github.com/cenkalti/backoff v2.0.0+incompatible h1:5IIPUHhlnUZbcHQsQou5k1Tn58nJkeJL9U+ig5CHJbY= +github.com/cenkalti/backoff v2.0.0+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= +github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= +github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= +github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/go-kit/kit v0.8.0 h1:Wz+5lgoB0kkuqLEc6NVmwRknTKP6dTGbSqvhZtBI/j0= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-logfmt/logfmt v0.4.0 h1:MP4Eh7ZCb31lleYCFuwm0oe4/YGak+5l1vA2NOE80nA= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-redis/redis v6.15.2+incompatible h1:9SpNVG76gr6InJGxoZ6IuuxaCOQwDAhzyXg+Bs+0Sb4= +github.com/go-redis/redis v6.15.2+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA= +github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.0 h1:0udJVsspx3VBr5FwtLhQQtuAsVc79tTq0ocGIPAU6qo= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/gorilla/mux v1.7.2 h1:zoNxOV7WjqXptQOVngLmcSQgXmgk4NMz1HibBchjl/I= +github.com/gorilla/mux v1.7.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= +github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q= +github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= +github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed h1:5upAirOpQc1Q53c0bnx2ufif5kANL7bfZWcc6VJWJd8= +github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed/go.mod h1:tMWxXQ9wFIaZeTI9F+hmhFiGpFmhOHzyShyFUhRm0H4= +github.com/hashicorp/consul/api v1.1.0 h1:BNQPM9ytxj6jbjjdRPioQ94T6YXriSopn0i8COv6SRA= +github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q= +github.com/hashicorp/consul/sdk v0.1.1 h1:LnuDWGNsoajlhGyHJvuWW6FVqRl8JOTPqS6CPTsYjhY= +github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= +github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-cleanhttp v0.5.1 h1:dH3aiDG9Jvb5r5+bYHsikaOUIpcM0xvgMXVoDkXMzJM= +github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-immutable-radix v1.1.0 h1:vN9wG1D6KG6YHRTWr8512cxGOVgTMEfgEdSj/hr8MPc= +github.com/hashicorp/go-immutable-radix v1.1.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= +github.com/hashicorp/go-msgpack v0.5.5 h1:i9R9JSrqIz0QVLz3sz+i3YJdT7TTSLcfLLzJi9aZTuI= +github.com/hashicorp/go-msgpack v0.5.5/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= +github.com/hashicorp/go-multierror v1.0.0 h1:iVjPR7a6H0tWELX5NxNe7bYopibicUzc7uPribsnS6o= +github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= +github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= +github.com/hashicorp/go-rootcerts v1.0.0 h1:Rqb66Oo1X/eSV1x66xbDccZjhJigjg0+e82kpwzSwCI= +github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= +github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= +github.com/hashicorp/go-sockaddr v1.0.2 h1:ztczhD1jLxIRjVejw8gFomI1BQZOe2WoVOu0SyteCQc= +github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjGlgmH/UkBUC97A= +github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= +github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.1 h1:fv1ep09latC32wFoVwnqcnKJGnMSdBanPczbHAYm1BE= +github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= +github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= +github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= +github.com/hashicorp/memberlist v0.1.4 h1:gkyML/r71w3FL8gUi74Vk76avkj/9lYAY9lvg0OcoGs= +github.com/hashicorp/memberlist v0.1.4/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= +github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= +github.com/hashicorp/serf v0.8.3 h1:MWYcmct5EtKz0efYooPcL0yNkem+7kWxqXDi/UIh+8k= +github.com/hashicorp/serf v0.8.3/go.mod h1:UpNcs7fFbpKIyZaUuSW6EPiH+eZC7OuyFD+wc1oal+k= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.2 h1:DB17ag19krx9CFsz4o3enTrPXyIXCl+2iCXH/aMAp9s= +github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515 h1:T+h1c/A9Gawja4Y9mFVWj2vyii2bbUNDw3kt9VxK2EY= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= +github.com/miekg/dns v1.1.13 h1:x7DQtkU0cedzeS8TD36tT/w1Hm4rDtfCaYYAHE7TTBI= +github.com/miekg/dns v1.1.13/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= +github.com/minio/highwayhash v1.0.0 h1:iMSDhgUILCr0TNm8LWlSjF8N0ZIj2qbO8WHp6Q/J2BA= +github.com/minio/highwayhash v1.0.0/go.mod h1:xQboMTeM9nY9v/LlAOxFctujiv5+Aq2hR5dxBpaMbdc= +github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= +github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-testing-interface v1.0.0 h1:fzU/JVNcaqHQEcVFAKeR41fkiLdIPrefOvVG1VZ96U0= +github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= +github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= +github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg= +github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= +github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/opentracing/opentracing-go v1.0.2 h1:3jA2P6O1F9UOrWVpwrIo17pu01KWvNWg4X946/Y5Zwg= +github.com/opentracing/opentracing-go v1.0.2/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= +github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY= +github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= +github.com/prometheus/client_golang v0.9.2/go.mod h1:OsXs2jCmiKlQ1lTBmv21f2mNfw4xf/QclQDMrYNZzcM= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/common v0.0.0-20181126121408-4724e9255275/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= +github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I= +github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= +github.com/sirupsen/logrus v1.0.6/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc= +github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.2.0 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48= +github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= +golang.org/x/crypto v0.0.0-20180820150726-614d502a4dac/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190530122614-20be4c3c3ed5 h1:8dUaAV7K4uHsF56JQWkprecIQKdPHtR9jCHF5nB8uzc= +golang.org/x/crypto v0.0.0-20190530122614-20be4c3c3ed5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3 h1:0GoQqolDA55aaLxZyTzK/Y2ePZzZTUrRacwib7cNsYQ= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180828065106-d99a578cf41b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190130150945-aca44879d564/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190531132440-69e3a3a65b5b h1:cuzzKXORsbIjh3IeOgOj0x2QA08ntIxmwi1mkRC3qoc= +golang.org/x/sys v0.0.0-20190531132440-69e3a3a65b5b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8 h1:Nw54tB0rB7hY/N0NQvRW8DG4Yk3Q6T9cu9RcFQDu1tc= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/grpc v1.21.0 h1:G+97AoqBnmZIT91cLG/EkCoK9NSelj64P8bOHHNmGn0= +google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/fatih/pool.v2 v2.0.0 h1:xIFeWtxifuQJGk/IEPKsTduEKcKvPmhoiVDGpC40nKg= +gopkg.in/fatih/pool.v2 v2.0.0/go.mod h1:8xVGeu1/2jr2wm5V9SPuMht2H5AEmf5aFMGSQixtjTY= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/rethinkdb/rethinkdb-go.v5 v5.0.1 h1:cBTUuUFTllRYtInK6FtBRa+tOBlZqpeDTnZF54xjGbg= +gopkg.in/rethinkdb/rethinkdb-go.v5 v5.0.1/go.mod h1:x+1XKi70FH0kHCpvPQ78hGBCCxoNdE7sP+kEFdKgN6A= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/session.go b/session.go new file mode 100644 index 0000000..2b3dc94 --- /dev/null +++ b/session.go @@ -0,0 +1,281 @@ +package draftbot + +import ( + "errors" + "fmt" + "math" + "math/rand" + + room "git.fromouter.space/mcg/cardgage/room/api" + + "git.fromouter.space/mcg/draft" + "git.fromouter.space/mcg/draft/mlp" + "git.fromouter.space/mcg/mlp-server-tools/draftbot/bot" +) + +// Session-related errors +var ( + ErrTooManyPlayers = errors.New("too many players") + ErrNotEnoughPlayers = errors.New("not enough players") +) + +type session struct { + Options DraftOptions + Players map[string]*draft.Player + Bots []*bot.Bot + Pod *draft.Pod + + // State management variables + started bool // Has the draft started already? + + // Channels for communication while the session is running + messages chan room.Message + exit chan bool +} + +// Types of drafts +const ( + DraftBlock = "block" + DraftSet = "set" + DraftCube = "cube" + DraftI8PCube = "i8pcube" +) + +// Ways in which players can be positioned along the draft pod +const ( + PosRandom = "random" // Place players randomly + PosEven = "even" // Place players spaced as evenly as possible +) + +// SessionOptions is the data contained in a create session request +type SessionOptions struct { + Players int `json:"players"` + Options DraftOptions `json:"options"` +} + +// DraftOptions are the options needed for a draft session +type DraftOptions struct { + Type string `json:"type"` + Positioning string `json:"positioning"` + + // Block draft properties + Block mlp.BlockID `json:"block,omitempty"` + + // Set draft properties + Set mlp.SetID `json:"set,omitempty"` + + // Cube draft properties + CubeURL string `json:"cube_url,omitempty"` + PackSize int `json:"pack_size,omitempty"` + + // I8PCube properties + MainCount int `json:"main_count,omitempty"` + ProblemCount int `json:"problem_count,omitempty"` + + // Shared + PackCount int `json:"pack_count,omitempty"` // Set and Cube +} + +func (do DraftOptions) getProvider() (draft.PackProvider, error) { + switch do.Type { + case DraftBlock: + return mlp.BlockPacks(do.Block) + case DraftSet: + set, err := mlp.LoadSetHTTP(do.Set) + if err != nil { + return nil, err + } + return draft.PacksFromSet(do.PackCount, set), nil + case DraftCube: + cards, err := loadCube(do.CubeURL) + if err != nil { + return nil, err + } + cube := &draft.GenericCube{ + Cards: cards, + PackSize: do.PackSize, + } + return draft.PacksFromSet(do.PackCount, cube), nil + case DraftI8PCube: + cube, err := loadI8PCube(do.CubeURL) + if err != nil { + return nil, err + } + return cube.PackProvider(do.MainCount, do.ProblemCount), nil + } + return nil, errors.New("unknown draft type") +} + +func newSession(playerCount int, opt DraftOptions) (*session, error) { + // Get pack provider for given options + provider, err := opt.getProvider() + if err != nil { + return nil, err + } + + return &session{ + Options: opt, + Pod: draft.MakePod(playerCount, provider), + Players: make(map[string]*draft.Player), + messages: make(chan room.Message), + exit: make(chan bool), + }, nil +} + +func (s *session) Start() error { + // Figure out how many players there are vs spots to be filled + spots := len(s.Pod.Players) + players := len(s.Players) + if players > spots { + return ErrTooManyPlayers + } + + if players < 1 { + return ErrNotEnoughPlayers + } + + // Assign players to their spot on the drafting pod + playerSpot := make(map[int]string) + + switch s.Options.Positioning { + case PosRandom: + // Assign a random number to each player + for pname := range s.Players { + var pos int + for { + pos = rand.Intn(spots) + // Make sure chosen number wasn't already picked + if _, ok := playerSpot[pos]; !ok { + break + } + } + playerSpot[pos] = pname + } + case PosEven: + // Space players evenly + playerRatio := float64(spots) / float64(players) + i := 0 + for name := range s.Players { + pos := int(math.Floor(playerRatio * float64(i))) + playerSpot[pos] = name + i++ + } + } + + // Prepare order to be broadcasted after all spots have been assigned + order := make([]string, len(s.Pod.Players)) + + // Assign player instances and make bots where needed + for i := range s.Pod.Players { + if name, ok := playerSpot[i]; ok { + s.Players[name] = s.Pod.Players[i] + order[i] = "player:" + name + } else { + s.Bots = append(s.Bots, bot.MakeBot(s.Pod.Players[i])) + order[i] = "bot" + } + } + + // Notify players of the order + s.messages <- room.Message{ + Channel: "draft", + Type: "draft-order", + Data: order, + } + + // Start handling packs + s.started = true + go s.handlePicks() + return nil +} + +func (s *session) handlePicks() { + // Pack loop, this `for` handles an entire draft session + totalPacks := len(s.Pod.Players[0].Packs) + currentPack := 0 + for { + err := s.Pod.OpenPacks() + if err != nil { + if err == draft.ErrNoPacksLeft { + // Notify players that the draft is over + s.messages <- room.Message{ + Channel: "draft", + Type: "draft-finish", + Message: "No more packs, the draft is over!", + } + // Send each player their deck + for pname, pdata := range s.Players { + s.messages <- room.Message{ + To: pname, + Type: "draft-picks", + Data: draft.Pack(pdata.Picks).IDs(), + Message: fmt.Sprintf("Your picks are: %s", pdata.Picks), + } + } + return + } + // Something is wrong! + //TODO + return + } + currentPack++ + s.messages <- room.Message{ + Channel: "draft", + Type: "draft-newpack", + Data: []int{currentPack, totalPacks}, + Message: fmt.Sprintf("Opening pack %d (of %d)", currentPack, totalPacks), + } + // Pick loop, this `for` handles exactly one round of packs + for { + s.messages <- room.Message{ + To: "draft", + Type: "draft-newpick", + Message: fmt.Sprintf("New pick for pack #%d", currentPack), + } + + // Make bots pick their cards + for _, bot := range s.Bots { + bot.PickNext() + } + + // Tell every players their new cards + for pname, player := range s.Players { + s.messages <- room.Message{ + To: pname, + Type: "draft-availablepicks", + Data: player.CurrentPack, + Message: fmt.Sprintf("You got these cards: %s", player.CurrentPack), + } + } + + nextPack := false + select { + case <-s.Pod.ReadyNextPack: + // Break of pick loop, get next packs + nextPack = true + case <-s.Pod.ReadyNextPick: + // Pass packs around + err := s.Pod.NextPacks() + if err != nil { + if err == draft.ErrNoPendingPack { + // No more picks to do for this round of packs, go to next + break + } else { + // Something is wrong! + //TODO + return + } + } + case <-s.exit: + // Room closed, exit early + close(s.messages) + close(s.exit) + return + } + + if nextPack { + break + } + } + } +} diff --git a/testdata/cube.go b/testdata/cube.go new file mode 100644 index 0000000..011a660 --- /dev/null +++ b/testdata/cube.go @@ -0,0 +1,130 @@ +package testdata + +// TestGenericCubeFile is an example file for a generic cube draft +const TestGenericCubeFile = `pr1 +pr2 +pr3 +pr4 +pr5 +pr6 +pr7 +pr8 +pr9 +pr10 +pr11 +pr12 +pr13 +pr14 +pr15 +pr16 +pr17 +pr18 +pr19 +pr20 +pr21 +pr22 +pr23 +pr24 +pr25 +pr26 +pr27 +pr28 +pr29 +pr30 +` + +// TestI8PCubeFile is an example file for a I8Pages' style cube draft +const TestI8PCubeFile = `{ + "Schema": [ + { "Amount": 1, "Type": "blue" }, + { "Amount": 1, "Type": "orange" }, + { "Amount": 1, "Type": "pink" }, + { "Amount": 1, "Type": "purple" }, + { "Amount": 1, "Type": "white" }, + { "Amount": 1, "Type": "yellow" }, + { "Amount": 1, "Type": "none" }, + { "Amount": 2, "Type": "multi" }, + { "Amount": 2, "Type": "entry" }, + { "Amount": 1, "Type": "all" } + ], + "Cards": { + "blue": [ + "pr54", + "pr54", + "pr54" + ], + "orange": [ + "pr54", + "pr54", + "pr54" + ], + "pink": [ + "pr54", + "pr54", + "pr54" + ], + "purple": [ + "pr54", + "pr54", + "pr54" + ], + "white": [ + "pr54", + "pr54", + "pr54" + ], + "yellow": [ + "pr54", + "pr54", + "pr54" + ], + "none": [ + "pr54", + "pr54", + "pr54" + ], + "multi": [ + "pr54", + "pr54", + "pr54", + "pr54", + "pr54", + "pr54" + ], + "entry": [ + "pr54", + "pr54", + "pr54", + "pr54", + "pr54", + "pr54" + ], + "problem": [ + "pr54", + "pr54", + "pr54", + "pr54", + "pr54", + "pr54", + "pr54", + "pr54", + "pr54", + "pr54", + "pr54", + "pr54", + "pr54", + "pr54", + "pr54", + "pr54", + "pr54", + "pr54", + "pr54", + "pr54", + "pr54", + "pr54", + "pr54", + "pr54", + "pr54" + ] + } +}`