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 { return games.db.Exec(ctx, `UPDATE users SET name=? WHERE uuid=?`, name, id) } 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) return result, err } func (s PlayerState) Points() int { points := 0 for _, kill := range s.Kills { points += kill.KillWord.Points } return points } func (games Games) GameByName(ctx context.Context, uid, name string) (string, error) { var result string err := games.db.Query(ctx, func(rows *sql.Rows) error { return rows.Scan(&result) }, ` SELECT players.game_uuid FROM players JOIN games ON players.game_uuid=games.uuid WHERE players.user_uuid=? AND games.name=? ORDER BY games.updated DESC LIMIT 1 `, uid, 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 KillWords 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 (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) v.Timestamp = timestamp return v, err case PlayerLeave: var v EventPlayerLeave err := json.Unmarshal(b, &v) v.Timestamp = timestamp return v, err case GameComplete: var v EventGameComplete err := json.Unmarshal(b, &v) v.Timestamp = timestamp return v, err case AssignmentRotation: var v EventAssignmentRotation err := json.Unmarshal(b, &v) v.Timestamp = timestamp return v, 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.KillWords { 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, name string) error { if err := games.db.Exec(ctx, ` INSERT INTO users ( uuid, name ) VALUES (?, ?) ON CONFLICT DO UPDATE SET name=? WHERE uuid=?; `, player, name, name, 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}) } //go:embed holiday.txt var wordsHoliday string 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 } now := time.Now() event := EventAssignmentRotation{ Type: AssignmentRotation, Killer: killer, Victim: victim, KillWord: KillWord{ Word: word, Points: points, }, KillWords: map[string]KillWords{}, } toAssign := []string{} doNotAssign := map[string]string{} for k, v := range state.Players { v := v.KillWords toAssign = append(toAssign, k) doNotAssign[k] = v.Assignee event.KillWords[k] = KillWords{ Global: v.Global, Assigned: now, Assignee: "", Assignment: v.Assignment, } } if killerState, ok := state.Players[killer]; !ok { } else if victimState, ok := state.Players[victim]; !ok { } else { event.KillWords[killer] = KillWords{ Global: killerState.KillWords.Global, Assigned: now, Assignee: victimState.KillWords.Assignee, Assignment: killerState.KillWords.Assignment, } toAssign = slices.DeleteFunc(toAssign, func(s string) bool { return s == event.KillWords[killer].Assignee }) if killerState.KillWords.Global.Word != word { victimState.KillWords.Assignment = Assignment{} state.Players[victim] = victimState } } for !func() bool { toAssign := slices.Clone(toAssign) doNotAssign := maps.Clone(doNotAssign) eventKillWords := maps.Clone(event.KillWords) for i := range toAssign { j := rand.Intn(i + 1) toAssign[i], toAssign[j] = toAssign[j], toAssign[i] } for k, v := range eventKillWords { if k == toAssign[0] || doNotAssign[k] == toAssign[0] { return false } eventKillWords[k] = KillWords{ Global: v.Global, Assigned: now, Assignee: toAssign[0], Assignment: v.Assignment, } toAssign = toAssign[1:] } event.KillWords = eventKillWords return true }() { } globalsInUse := map[string]any{} publicsInUse := map[string]any{} privatesInUse := map[string]any{} for _, v := range event.KillWords { globalsInUse[v.Global.Word] = nil for _, public := range v.Assignment.Public { publicsInUse[public.Word] = nil } for _, private := range v.Assignment.Private { privatesInUse[private.Word] = nil } } randWord := func(words string, taken map[string]any) string { wordsList := strings.Fields(words) for { got := wordsList[rand.Intn(len(wordsList))] if _, ok := taken[got]; !ok { taken[got] = nil return got } } } randGlobal := func() string { return randWord(wordsHoliday, globalsInUse) } randPublic := func() string { return randWord(wordsHoliday, publicsInUse) } randPrivate := func() string { return randWord(wordsHoliday, privatesInUse) } // TODO generate .Global=...us major cities?, .Assignments.Public=...?, .Assignments.Private=holiday for k, v := range event.KillWords { if v.Global.Word == "" { v.Global = KillWord{Word: randGlobal(), Points: 1} } if len(v.Assignment.Public) == 0 { v.Assignment.Public = []KillWord{ KillWord{Word: randPublic(), Points: 50}, KillWord{Word: randPublic(), Points: 50}, } } if len(v.Assignment.Private) == 0 { v.Assignment.Private = []KillWord{ KillWord{Word: randPrivate(), Points: 100}, } } event.KillWords[k] = v } return games.createEvent(ctx, id, event) } 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) }