out/cmd/server/games.go

430 lines
9.3 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) 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 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
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(&timestamp, &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})
}
//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)
}