package main import ( "context" "database/sql" _ "embed" "encoding/json" "fmt" "maps" "math/rand" "slices" "strings" "time" "github.com/google/uuid" ) type Games struct { db DB } func NewGames(ctx context.Context, db DB) (Games, error) { err := db.Exec(ctx, ` CREATE TABLE IF NOT EXISTS users ( uuid TEXT, updated DATETIME, name TEXT ); CREATE TABLE IF NOT EXISTS games ( uuid TEXT, updated DATETIME, name TEXT, completed DATETIME ); CREATE TABLE IF NOT EXISTS players ( user_uuid TEXT, game_uuid TEXT, updated DATETIME ); CREATE TABLE IF NOT EXISTS events ( game_uuid TEXT, timestamp DATETIME, payload TEXT ); `) return Games{db: db}, err } func (games Games) GamesForUser(ctx context.Context, id string) ([]string, error) { result := []string{} err := games.db.Query(ctx, func(rows *sql.Rows) error { var game string err := rows.Scan(&game) result = append(result, game) return err }, ` SELECT players.game_uuid FROM players WHERE players.user_uuid=? `, id) return result, err } func (games Games) UpdateUserName(ctx context.Context, id, name string) error { var n int if err := games.db.Query(ctx, func(rows *sql.Rows) error { return rows.Scan(&n) }, `SELECT COUNT(uuid) FROM users WHERE uuid=?`, id); err != nil { return err } if n > 0 { return games.db.Exec(ctx, `UPDATE users SET name=? WHERE uuid=?`, name, id) } return games.db.Exec(ctx, `INSERT INTO users (uuid, name) VALUES (?, ?)`, id, name) } //go:embed adjectives.txt var namesAdjectives string //go:embed animals.txt var namesAnimals string func (games Games) UserName(ctx context.Context, id string) (string, error) { result := "" err := games.db.Query(ctx, func(rows *sql.Rows) error { return rows.Scan(&result) }, ` SELECT users.name FROM users WHERE users.uuid=? `, id) if result == "" { adjectives := strings.Fields(namesAdjectives) animals := strings.Split(namesAnimals, "\n") animals = slices.DeleteFunc(animals, func(s string) bool { return s == "" }) name := strings.Title(fmt.Sprintf("%s %s", adjectives[rand.Intn(len(adjectives))], animals[rand.Intn(len(animals))])) if err := games.UpdateUserName(ctx, id, name); err != nil { return "", err } return games.UserName(ctx, id) } return result, err } func (games Games) UserByName(ctx context.Context, gid, name string) (string, error) { result := "" err := games.db.Query(ctx, func(rows *sql.Rows) error { return rows.Scan(&result) }, ` SELECT users.uuid FROM players JOIN users ON players.user_uuid=users.uuid WHERE players.game_uuid=? AND users.name=? `, gid, name) return result, err } func (a KillWord) Empty() bool { return a == (KillWord{}) } func (a Assignment) Empty() bool { return len(a.Public) == 0 && len(a.Private) == 0 } func (s PlayerState) Empty() bool { return len(s.Kills) == 0 && s.KillWords.Empty() } func (s PlayerState) Points() int { points := 0 for _, kill := range s.Kills { points += kill.KillWord.Points } return points } func (games Games) GameName(ctx context.Context, id string) (string, error) { var result string err := games.db.Query(ctx, func(rows *sql.Rows) error { return rows.Scan(&result) }, ` SELECT games.name FROM games WHERE games.uuid=? ORDER BY games.updated DESC LIMIT 1 `, id) return result, err } func (games Games) GameByName(ctx context.Context, name string) (string, error) { var result string err := games.db.Query(ctx, func(rows *sql.Rows) error { return rows.Scan(&result) }, ` SELECT games.uuid FROM games WHERE games.name=? ORDER BY games.updated DESC LIMIT 1 `, name) return result, err } type ( GameState struct { Started bool Completed time.Time Players map[string]PlayerState } PlayerState struct { Kills []Kill KillWords KillWords } Kill struct { Timestamp time.Time Victim string KillWord KillWord } KillWords struct { Global KillWord Assigned time.Time Assignee string Assignment Assignment } Assignment struct { Public []KillWord Private []KillWord } KillWord struct { Word string Points int } EventType int EventPlayerJoin struct { Type EventType Timestamp time.Time ID string } EventPlayerLeave struct { Type EventType Timestamp time.Time ID string } EventGameComplete struct { Type EventType Timestamp time.Time } EventAssignmentRotation struct { Type EventType Timestamp time.Time Killer string Victim string KillWord KillWord AllKillWords AllKillWords } AllKillWords map[string]KillWords ) const ( PlayerJoin EventType = iota + 1 PlayerLeave GameComplete AssignmentRotation ) type Event interface{ event() } func (EventPlayerJoin) event() {} func (EventPlayerLeave) event() {} func (EventGameComplete) event() {} func (EventAssignmentRotation) event() {} func EventWithTime(event Event, t time.Time) Event { switch e := event.(type) { case EventPlayerJoin: e.Timestamp = t event = e case EventPlayerLeave: e.Timestamp = t event = e case EventGameComplete: e.Timestamp = t event = e case EventAssignmentRotation: e.Timestamp = t event = e } return event } func (games Games) GameEvents(ctx context.Context, id string, since time.Time) ([]Event, error) { var results []Event err := games.db.Query(ctx, func(rows *sql.Rows) error { var timestamp time.Time var b []byte if err := rows.Scan(×tamp, &b); err != nil { return err } event, err := parseEvent(b, timestamp) results = append(results, event) return err }, ` SELECT timestamp, payload FROM events WHERE game_uuid=? and timestamp>? ORDER BY timestamp ASC `, id, since) return results, err } func parseEvent(b []byte, timestamp time.Time) (Event, error) { var peek struct { Type EventType } if err := json.Unmarshal(b, &peek); err != nil { return nil, err } switch peek.Type { case PlayerJoin: var v EventPlayerJoin err := json.Unmarshal(b, &v) return EventWithTime(v, timestamp), err case PlayerLeave: var v EventPlayerLeave err := json.Unmarshal(b, &v) return EventWithTime(v, timestamp), err case GameComplete: var v EventGameComplete err := json.Unmarshal(b, &v) return EventWithTime(v, timestamp), err case AssignmentRotation: var v EventAssignmentRotation err := json.Unmarshal(b, &v) return EventWithTime(v, timestamp), err } return nil, fmt.Errorf("unknown event type %d: %s", peek.Type, b) } func (games Games) GameState(ctx context.Context, id string) (GameState, error) { result := GameState{Players: map[string]PlayerState{}} events, err := games.GameEvents(ctx, id, time.Time{}) if err != nil { return result, err } for _, event := range events { switch event.(type) { case EventPlayerJoin: playerJoin := event.(EventPlayerJoin) result.Players[playerJoin.ID] = PlayerState{} case EventPlayerLeave: playerLeave := event.(EventPlayerLeave) delete(result.Players, playerLeave.ID) case EventGameComplete: gameComplete := event.(EventGameComplete) result.Completed = gameComplete.Timestamp case EventAssignmentRotation: result.Started = true assignmentRotation := event.(EventAssignmentRotation) if killer, ok := result.Players[assignmentRotation.Killer]; !ok { } else if _, ok := result.Players[assignmentRotation.Victim]; !ok { } else { killer.Kills = append(killer.Kills, Kill{ Timestamp: assignmentRotation.Timestamp, Victim: assignmentRotation.Victim, KillWord: assignmentRotation.KillWord, }) result.Players[assignmentRotation.Killer] = killer } for k, v := range result.Players { v.KillWords = KillWords{} result.Players[k] = v } for k, v := range assignmentRotation.AllKillWords { player := result.Players[k] player.KillWords = v result.Players[k] = player } default: return GameState{}, fmt.Errorf("unknown event type %T", event) } } return result, err } func (games Games) CreateGame(ctx context.Context, name string) (string, error) { var exists string if err := games.db.Query(ctx, func(rows *sql.Rows) error { return rows.Scan(&exists) }, ` SELECT uuid FROM games WHERE name=? AND completed IS NULL `, name); err != nil { return "", err } else if exists != "" { return exists, nil } id := uuid.New().String() return id, games.db.Exec(ctx, ` INSERT INTO games ( uuid, updated, name ) VALUES (?, ?, ?) `, id, time.Now(), name) } func (games Games) CreateEventPlayerJoin(ctx context.Context, id string, player string) error { if _, err := games.UserName(ctx, player); err != nil { return err } if err := games.db.Exec(ctx, ` INSERT INTO players ( game_uuid, user_uuid ) VALUES (?, ?); `, id, player); err != nil { return err } return games.createEvent(ctx, id, EventPlayerJoin{Type: PlayerJoin, ID: player}) } func (games Games) CreateEventPlayerLeave(ctx context.Context, id string, player string) error { return games.createEvent(ctx, id, EventPlayerLeave{Type: PlayerLeave, ID: player}) } func (games Games) CreateEventAssignmentRotation(ctx context.Context, id string, killer, victim, word string, points int) error { state, err := games.GameState(ctx, id) if err != nil { return err } event := EventAssignmentRotation{ Type: AssignmentRotation, Killer: killer, Victim: victim, KillWord: KillWord{ Word: word, Points: points, }, } prevAllKillWords := make(AllKillWords) for k, v := range state.Players { prevAllKillWords[k] = v.KillWords } event.AllKillWords = prevAllKillWords.ShuffleAssignees(killer, victim, word) event.AllKillWords = event.AllKillWords.FillKillWords() return games.createEvent(ctx, id, event) } func (words KillWords) Empty() bool { return words.Global == (KillWord{}) && words.Assigned.IsZero() && words.Assignee == "" && words.Assignment.Empty() } func (words KillWords) Privates() []KillWord { a := slices.Clone(words.Assignment.Private) slices.SortFunc(a, func(a, b KillWord) int { return strings.Compare(a.Word, b.Word) }) return a } func (words KillWords) Publics() []KillWord { a := slices.Clone(words.Assignment.Public) slices.SortFunc(a, func(a, b KillWord) int { return strings.Compare(a.Word, b.Word) }) return a } func (prev AllKillWords) ShuffleAssignees(killer, victim, word string) AllKillWords { m := prev.withoutAssignees() if _, ok := prev[killer]; !ok { } else if victimState, ok := prev[victim]; !ok { } else if killer != victimState.Assignee { // if victim was targeting killer, just randomize m.assign(killer, victimState.Assignee) } for !func() bool { allKillWords := maps.Clone(m) unassigned := allKillWords.unassigned() pop := func() string { result := unassigned[0] unassigned = unassigned[1:] return result } for k, v := range allKillWords { if v.Assignee != "" { continue } v.Assignee = pop() if k == v.Assignee || prev[k].Assignee == v.Assignee { return false } allKillWords[k] = v } if len(unassigned) > 0 { panic(unassigned) } m = allKillWords return true }() { } return m } func (m AllKillWords) assign(killer, victim string) { v := m[killer] v.Assignee = victim m[killer] = v } func (m AllKillWords) withoutAssignees() AllKillWords { now := time.Now() result := make(AllKillWords) for k := range m { result[k] = KillWords{ Global: m[k].Global, Assigned: now, Assignee: "", Assignment: m[k].Assignment, } } return result } func (m AllKillWords) unassigned() []string { var result []string for k := range m { if !slices.Contains(m.assigned(), k) { result = append(result, k) } } for i := range result { j := rand.Intn(i + 1) result[i], result[j] = result[j], result[i] } return result } func (m AllKillWords) assigned() []string { var result []string for k := range m { v := m[k].Assignee if v == "" { continue } result = append(result, v) } return result } //go:embed holiday.txt var wordsHoliday string func (m AllKillWords) FillKillWords() AllKillWords { return m.fillKillWords( strings.Fields(wordsHoliday), 1, strings.Fields(wordsHoliday), // TODO medium difficulty 2, strings.Fields(wordsHoliday), // TODO hard difficulty ) } func (m AllKillWords) fillKillWords( poolGlobal []string, nPublic int, poolPublic []string, nPrivate int, poolPrivate []string, ) AllKillWords { result := maps.Clone(m) m = result for k, v := range m { if v.Global.Word == "" { v.Global = KillWord{Word: m.unusedGlobal(poolGlobal), Points: -1} m[k] = v } if len(v.Assignment.Public) == 0 { v.Assignment.Public = []KillWord{} for i := 0; i < nPublic; i++ { v.Assignment.Public = append(v.Assignment.Public, KillWord{Word: m.unusedPublic(poolPublic), Points: 50}) m[k] = v } } if len(v.Assignment.Private) == 0 { v.Assignment.Private = []KillWord{} for i := 0; i < nPrivate; i++ { v.Assignment.Private = append(v.Assignment.Private, KillWord{Word: m.unusedPrivate(poolPrivate), Points: 100}) m[k] = v } } } return m } func (m AllKillWords) unusedGlobal(pool []string) string { inUse := func() []string { result := []string{} for _, killWords := range m { result = append(result, killWords.Global.Word) } return result } for { picked := pool[rand.Intn(len(pool))] if !slices.Contains(inUse(), picked) { return picked } } } func (m AllKillWords) unusedPrivate(pool []string) string { inUse := func() []string { result := []string{} for _, killWords := range m { for _, killWord := range killWords.Assignment.Private { result = append(result, killWord.Word) } } return result } for { picked := pool[rand.Intn(len(pool))] if !slices.Contains(inUse(), picked) { return picked } } } func (m AllKillWords) unusedPublic(pool []string) string { inUse := func() []string { result := []string{} for _, killWords := range m { for _, killWord := range killWords.Assignment.Public { result = append(result, killWord.Word) } } return result } for { picked := pool[rand.Intn(len(pool))] if !slices.Contains(inUse(), picked) { return picked } } } func (games Games) CreateEventGameComplete(ctx context.Context, id string) error { if err := games.db.Exec(ctx, ` UPDATE games SET completed=?, updated=? WHERE uuid=? `, time.Now(), time.Now(), id); err != nil { return err } return games.createEvent(ctx, id, EventGameComplete{Type: GameComplete}) } func (games Games) createEvent(ctx context.Context, id string, v any) error { payload, err := json.Marshal(v) if err != nil { panic(err) } return games.db.Exec(ctx, ` INSERT INTO events ( game_uuid, timestamp, payload ) VALUES (?, ?, ?) `, id, time.Now(), payload) }