361 lines
7.6 KiB
Go
361 lines
7.6 KiB
Go
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)
|
|
}
|