package main import ( "context" "database/sql" "encoding/json" "fmt" "maps" "math/rand" "slices" "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) 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 { 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 string 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 ID string } EventPlayerLeave struct { Type EventType ID string } EventGameComplete struct { Type EventType } EventAssignmentRotation struct { Type EventType Killer string Victim string KillWord KillWord KillWords map[string]KillWords } ) const ( PlayerJoin EventType = iota + 1 PlayerLeave GameComplete AssignmentRotation ) func (games Games) GameState(ctx context.Context, id string) (GameState, error) { result := GameState{Players: map[string]PlayerState{}} err := games.db.Query(ctx, func(rows *sql.Rows) error { var timestamp time.Time var payload []byte if err := rows.Scan(×tamp, &payload); err != nil { return err } var peek struct { Type EventType } if err := json.Unmarshal(payload, &peek); err != nil { return err } switch peek.Type { case PlayerJoin: var playerJoin EventPlayerJoin if err := json.Unmarshal(payload, &playerJoin); err != nil { return err } result.Players[playerJoin.ID] = PlayerState{} return nil case PlayerLeave: var playerLeave EventPlayerLeave if err := json.Unmarshal(payload, &playerLeave); err != nil { return err } delete(result.Players, playerLeave.ID) return nil case GameComplete: var gameComplete EventGameComplete if err := json.Unmarshal(payload, &gameComplete); err != nil { return err } result.Completed = timestamp return nil case AssignmentRotation: var assignmentRotation EventAssignmentRotation if err := json.Unmarshal(payload, &assignmentRotation); err != nil { return err } if killer, ok := result.Players[assignmentRotation.Killer]; !ok { } else if _, ok := result.Players[assignmentRotation.Victim]; !ok { } else { killer.Kills = append(killer.Kills, Kill{ Timestamp: 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 } return nil default: return fmt.Errorf("unknown event type %d: %s", peek.Type, payload) } return nil }, ` SELECT timestamp, payload FROM events WHERE game_uuid=? `, id) 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.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 } 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 }) } 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 }() { } return games.createEvent(ctx, id, event) } func (games Games) CreateEventGameComplete(ctx context.Context, id string) error { 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) }