out/cmd/server/games.go

618 lines
13 KiB
Go

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 (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) 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(&timestamp, &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
}
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 {
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)
}