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 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, 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}) } 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, }, AllKillWords: make(AllKillWords), } toAssign := []string{} doNotAssign := map[string]string{} for k, v := range state.Players { v := v.KillWords toAssign = append(toAssign, k) doNotAssign[k] = v.Assignee event.AllKillWords[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.AllKillWords[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.AllKillWords[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) allKillWords := maps.Clone(event.AllKillWords) for i := range toAssign { j := rand.Intn(i + 1) toAssign[i], toAssign[j] = toAssign[j], toAssign[i] } for k, v := range allKillWords { if k == toAssign[0] || doNotAssign[k] == toAssign[0] { return false } allKillWords[k] = KillWords{ Global: v.Global, Assigned: now, Assignee: toAssign[0], Assignment: v.Assignment, } toAssign = toAssign[1:] } event.AllKillWords = allKillWords return true }() { } for k, v := range event.AllKillWords { if v.Global.Word == "" { v.Global = KillWord{Word: event.AllKillWords.unusedGlobal(), Points: -1} event.AllKillWords[k] = v } if len(v.Assignment.Public) == 0 { v.Assignment.Public = []KillWord{} for i := 0; i < 2; i++ { v.Assignment.Public = append(v.Assignment.Public, KillWord{Word: event.AllKillWords.unusedPublic(), Points: 50}) event.AllKillWords[k] = v } } if len(v.Assignment.Private) == 0 { v.Assignment.Private = []KillWord{} for i := 0; i < 2; i++ { v.Assignment.Private = append(v.Assignment.Private, KillWord{Word: event.AllKillWords.unusedPrivate(), Points: 100}) event.AllKillWords[k] = v } } } return games.createEvent(ctx, id, event) } //go:embed holiday.txt var wordsHoliday string func (m AllKillWords) unusedGlobal() string { pool := strings.Fields(wordsHoliday) 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 } } } // TODO hard difficulty func (m AllKillWords) unusedPrivate() string { pool := strings.Fields(wordsHoliday) 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 } } } // TODO medium difficulty func (m AllKillWords) unusedPublic() string { pool := strings.Fields(wordsHoliday) 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) }