Compare commits
19 Commits
78511f25f3
...
808266d9c9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
808266d9c9 | ||
|
|
a99cb0e102 | ||
|
|
ba21848373 | ||
|
|
cb335639dd | ||
|
|
264b165f1c | ||
|
|
5587467a2d | ||
|
|
2a635bc8b5 | ||
|
|
62df483665 | ||
|
|
ceff02789a | ||
|
|
67ff6284bf | ||
|
|
8115bdf3ff | ||
|
|
b9392a38e2 | ||
|
|
0a944f3b0a | ||
|
|
f17328a9ee | ||
|
|
130f8122b6 | ||
|
|
9dd661ecd0 | ||
|
|
39b1a6a1e8 | ||
|
|
659bf0f559 | ||
|
|
58904c8619 |
@@ -3,7 +3,6 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"io"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -14,16 +13,19 @@ type DB struct {
|
|||||||
scheme string
|
scheme string
|
||||||
conn string
|
conn string
|
||||||
rw *sync.RWMutex
|
rw *sync.RWMutex
|
||||||
|
locked *bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDB(ctx context.Context, scheme, conn string) (DB, error) {
|
func NewDB(ctx context.Context, scheme, conn string) (DB, error) {
|
||||||
ctx, can := context.WithTimeout(ctx, time.Second*10)
|
ctx, can := context.WithTimeout(ctx, time.Second*10)
|
||||||
defer can()
|
defer can()
|
||||||
|
|
||||||
|
locked := false
|
||||||
db := DB{
|
db := DB{
|
||||||
scheme: scheme,
|
scheme: scheme,
|
||||||
conn: conn,
|
conn: conn,
|
||||||
rw: &sync.RWMutex{},
|
rw: &sync.RWMutex{},
|
||||||
|
locked: &locked,
|
||||||
}
|
}
|
||||||
|
|
||||||
sql, err := db.dial(ctx)
|
sql, err := db.dial(ctx)
|
||||||
@@ -38,12 +40,18 @@ func NewDB(ctx context.Context, scheme, conn string) (DB, error) {
|
|||||||
func (db DB) WithLock(cb func() error) error {
|
func (db DB) WithLock(cb func() error) error {
|
||||||
db.rw.Lock()
|
db.rw.Lock()
|
||||||
defer db.rw.Unlock()
|
defer db.rw.Unlock()
|
||||||
|
*db.locked = true
|
||||||
|
defer func() {
|
||||||
|
*db.locked = false
|
||||||
|
}()
|
||||||
return cb()
|
return cb()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db DB) Exec(ctx context.Context, q string, args ...any) error {
|
func (db DB) Exec(ctx context.Context, q string, args ...any) error {
|
||||||
|
if !*db.locked {
|
||||||
db.rw.RLock()
|
db.rw.RLock()
|
||||||
defer db.rw.RUnlock()
|
defer db.rw.RUnlock()
|
||||||
|
}
|
||||||
return db.exec(ctx, q, args...)
|
return db.exec(ctx, q, args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,8 +67,10 @@ func (db DB) exec(ctx context.Context, q string, args ...any) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (db DB) Query(ctx context.Context, cb func(*sql.Rows) error, q string, args ...any) error {
|
func (db DB) Query(ctx context.Context, cb func(*sql.Rows) error, q string, args ...any) error {
|
||||||
|
if !*db.locked {
|
||||||
db.rw.RLock()
|
db.rw.RLock()
|
||||||
defer db.rw.RUnlock()
|
defer db.rw.RUnlock()
|
||||||
|
}
|
||||||
return db.query(ctx, cb, q, args...)
|
return db.query(ctx, cb, q, args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,7 +106,3 @@ func (db DB) dial(ctx context.Context) (*sql.DB, error) {
|
|||||||
}
|
}
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db DB) GetParty(id string) (string, error) {
|
|
||||||
return "", io.EOF
|
|
||||||
}
|
|
||||||
|
|||||||
95
cmd/server/db_test.go
Normal file
95
cmd/server/db_test.go
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"path"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestDB(t *testing.T) DB {
|
||||||
|
ctx, can := context.WithTimeout(context.Background(), time.Minute)
|
||||||
|
defer can()
|
||||||
|
conn := path.Join(t.TempDir(), "db")
|
||||||
|
|
||||||
|
db, err := NewDB(ctx, "sqlite", conn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDB(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
db := newTestDB(t)
|
||||||
|
|
||||||
|
t.Run("with lock", func(t *testing.T) {
|
||||||
|
var called [2]bool
|
||||||
|
if err := db.WithLock(func() error {
|
||||||
|
for i := range called {
|
||||||
|
if err := db.Query(ctx, func(rows *sql.Rows) error {
|
||||||
|
return rows.Scan(&called[i])
|
||||||
|
}, `SELECT true`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !called[0] {
|
||||||
|
t.Error(0)
|
||||||
|
}
|
||||||
|
if !called[1] {
|
||||||
|
t.Error(1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("exec, query", func(t *testing.T) {
|
||||||
|
if err := db.Exec(ctx, `
|
||||||
|
CREATE TABLE IF NOT EXISTS my_table (
|
||||||
|
text TEXT,
|
||||||
|
datetime DATETIME,
|
||||||
|
number NUMBER
|
||||||
|
)
|
||||||
|
`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Exec(ctx, `
|
||||||
|
INSERT INTO my_table (
|
||||||
|
text,
|
||||||
|
datetime,
|
||||||
|
number
|
||||||
|
) VALUES (?, ?, ?)
|
||||||
|
`, "text", time.Now(), 1); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var text string
|
||||||
|
var datetime time.Time
|
||||||
|
var number int
|
||||||
|
if err := db.Query(ctx, func(rows *sql.Rows) error {
|
||||||
|
return rows.Scan(&text, &datetime, &number)
|
||||||
|
}, `
|
||||||
|
SELECT
|
||||||
|
text,
|
||||||
|
datetime,
|
||||||
|
number
|
||||||
|
FROM my_table
|
||||||
|
`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if text != "text" {
|
||||||
|
t.Error(text)
|
||||||
|
}
|
||||||
|
if datetime.IsZero() {
|
||||||
|
t.Error(datetime)
|
||||||
|
}
|
||||||
|
if number != 1 {
|
||||||
|
t.Error(number)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -3,11 +3,16 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
_ "embed"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"maps"
|
||||||
|
"math/rand"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Games struct {
|
type Games struct {
|
||||||
@@ -56,12 +61,50 @@ func (games Games) GamesForUser(ctx context.Context, id string) ([]string, error
|
|||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (games Games) GameByName(ctx context.Context, name string) (string, error) {
|
func (games Games) UpdateUserName(ctx context.Context, id, name string) error {
|
||||||
return "", io.EOF
|
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 (
|
type (
|
||||||
GameState struct {
|
GameState struct {
|
||||||
|
Started bool
|
||||||
Completed time.Time
|
Completed time.Time
|
||||||
Players map[string]PlayerState
|
Players map[string]PlayerState
|
||||||
}
|
}
|
||||||
@@ -74,11 +117,11 @@ type (
|
|||||||
Kill struct {
|
Kill struct {
|
||||||
Timestamp time.Time
|
Timestamp time.Time
|
||||||
Victim string
|
Victim string
|
||||||
Public bool
|
KillWord KillWord
|
||||||
}
|
}
|
||||||
|
|
||||||
KillWords struct {
|
KillWords struct {
|
||||||
Global string
|
Global KillWord
|
||||||
|
|
||||||
Assigned time.Time
|
Assigned time.Time
|
||||||
Assignee string
|
Assignee string
|
||||||
@@ -87,23 +130,33 @@ type (
|
|||||||
}
|
}
|
||||||
|
|
||||||
Assignment struct {
|
Assignment struct {
|
||||||
Public []string
|
Public []KillWord
|
||||||
Private []string
|
Private []KillWord
|
||||||
|
}
|
||||||
|
|
||||||
|
KillWord struct {
|
||||||
|
Word string
|
||||||
|
Points int
|
||||||
}
|
}
|
||||||
|
|
||||||
EventType int
|
EventType int
|
||||||
|
|
||||||
EventPlayerJoin struct {
|
EventPlayerJoin struct {
|
||||||
|
Type EventType
|
||||||
ID string
|
ID string
|
||||||
}
|
}
|
||||||
EventPlayerLeave struct {
|
EventPlayerLeave struct {
|
||||||
|
Type EventType
|
||||||
ID string
|
ID string
|
||||||
}
|
}
|
||||||
EventGameComplete struct{}
|
EventGameComplete struct {
|
||||||
|
Type EventType
|
||||||
|
}
|
||||||
EventAssignmentRotation struct {
|
EventAssignmentRotation struct {
|
||||||
|
Type EventType
|
||||||
Killer string
|
Killer string
|
||||||
Killed string
|
Victim string
|
||||||
KillWord string
|
KillWord KillWord
|
||||||
KillWords map[string]KillWords
|
KillWords map[string]KillWords
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -155,18 +208,19 @@ func (games Games) GameState(ctx context.Context, id string) (GameState, error)
|
|||||||
result.Completed = timestamp
|
result.Completed = timestamp
|
||||||
return nil
|
return nil
|
||||||
case AssignmentRotation:
|
case AssignmentRotation:
|
||||||
|
result.Started = true
|
||||||
var assignmentRotation EventAssignmentRotation
|
var assignmentRotation EventAssignmentRotation
|
||||||
if err := json.Unmarshal(payload, &assignmentRotation); err != nil {
|
if err := json.Unmarshal(payload, &assignmentRotation); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if killer, ok := result.Players[assignmentRotation.Killer]; !ok {
|
if killer, ok := result.Players[assignmentRotation.Killer]; !ok {
|
||||||
} else if victim, ok := result.Players[assignmentRotation.Killed]; !ok {
|
} else if _, ok := result.Players[assignmentRotation.Victim]; !ok {
|
||||||
} else {
|
} else {
|
||||||
killer.Kills = append(killer.Kills, Kill{
|
killer.Kills = append(killer.Kills, Kill{
|
||||||
Timestamp: timestamp,
|
Timestamp: timestamp,
|
||||||
Victim: assignmentRotation.Killed,
|
Victim: assignmentRotation.Victim,
|
||||||
Public: slices.Contains(victim.KillWords.Assignment.Public, assignmentRotation.KillWord),
|
KillWord: assignmentRotation.KillWord,
|
||||||
})
|
})
|
||||||
result.Players[assignmentRotation.Killer] = killer
|
result.Players[assignmentRotation.Killer] = killer
|
||||||
}
|
}
|
||||||
@@ -196,29 +250,206 @@ func (games Games) GameState(ctx context.Context, id string) (GameState, error)
|
|||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (games Games) CreateEventPlayerJoin(ctx context.Context, id string, player string) error {
|
func (games Games) CreateGame(ctx context.Context, name string) (string, error) {
|
||||||
return games.createEvent(ctx, id, EventPlayerJoin{ID: player})
|
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 {
|
func (games Games) CreateEventPlayerLeave(ctx context.Context, id string, player string) error {
|
||||||
return games.createEvent(ctx, id, EventPlayerLeave{ID: player})
|
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 {
|
func (games Games) CreateEventGameComplete(ctx context.Context, id string) error {
|
||||||
return games.createEvent(ctx, id, EventGameComplete{})
|
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) CreateEventAssignmentRotation(ctx context.Context, id string, killer, killed, killWord string) error {
|
|
||||||
// TODO gather current assignees
|
|
||||||
// TODO get victim's target
|
|
||||||
// TODO assign victim's target to killer
|
|
||||||
// TODO randomize everyone else so not the same as before AND not self
|
|
||||||
return io.EOF
|
|
||||||
return games.createEvent(ctx, id, v)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (games Games) createEvent(ctx context.Context, id string, v any) error {
|
func (games Games) createEvent(ctx context.Context, id string, v any) error {
|
||||||
payload, err := json.Marshal(any)
|
payload, err := json.Marshal(v)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
|||||||
121
cmd/server/games_test.go
Normal file
121
cmd/server/games_test.go
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestGames(t *testing.T) Games {
|
||||||
|
db := newTestDB(t)
|
||||||
|
|
||||||
|
games, err := NewGames(context.Background(), db)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return games
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGames(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
t.Run("empty", func(t *testing.T) {
|
||||||
|
games := newTestGames(t)
|
||||||
|
|
||||||
|
if v, err := games.GamesForUser(ctx, ""); err != nil {
|
||||||
|
t.Error("err getting games for empty user:", err)
|
||||||
|
} else if len(v) > 0 {
|
||||||
|
t.Error(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
if v, err := games.GameByName(ctx, "", ""); err != nil {
|
||||||
|
t.Error("err getting game by empty name for empty user:", err)
|
||||||
|
} else if len(v) > 0 {
|
||||||
|
t.Error(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
if v, err := games.GameState(ctx, ""); err != nil {
|
||||||
|
t.Error("err getting game state for empty:", err)
|
||||||
|
} else if len(v.Players) > 0 || !v.Completed.IsZero() {
|
||||||
|
t.Error(v)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("mvp", func(t *testing.T) {
|
||||||
|
games := newTestGames(t)
|
||||||
|
|
||||||
|
id, err := games.CreateGame(ctx, "g1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal("err creating game:", err)
|
||||||
|
} else if id2, err := games.CreateGame(ctx, "g1"); err != nil {
|
||||||
|
t.Fatal("err creating game redundantly:", err)
|
||||||
|
} else if id != id2 {
|
||||||
|
t.Fatal("redundant create game didnt return same id:", id2)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := games.CreateEventPlayerJoin(ctx, id, "p0", "player zero"); err != nil {
|
||||||
|
t.Fatal("err creating event player join:", err)
|
||||||
|
} else if err := games.CreateEventPlayerLeave(ctx, id, "p0"); err != nil {
|
||||||
|
t.Fatal("err creating event player leave:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
p := fmt.Sprintf("p%d", i+1)
|
||||||
|
if err := games.CreateEventPlayerJoin(ctx, id, p, "player "+p); err != nil {
|
||||||
|
t.Fatal(p, "err creating event player join", err)
|
||||||
|
}
|
||||||
|
if name, err := games.UserName(ctx, p); err != nil {
|
||||||
|
t.Fatal(p, "err getting user name", err)
|
||||||
|
} else if name == "" {
|
||||||
|
t.Fatal("name empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := games.CreateEventAssignmentRotation(ctx, id, "", "", "", 1); err != nil {
|
||||||
|
t.Fatal("err creating rotation:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if v, err := games.GamesForUser(ctx, "p1"); err != nil {
|
||||||
|
t.Error("err getting games for user:", err)
|
||||||
|
} else if len(v) < 1 {
|
||||||
|
t.Error("no games found for user:", v)
|
||||||
|
} else if v[0] != id {
|
||||||
|
t.Error("wrong game found for user:", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
if v, err := games.GameByName(ctx, "p1", "g1"); err != nil {
|
||||||
|
t.Error("err getting game by name for user:", err)
|
||||||
|
} else if v != id {
|
||||||
|
t.Error("wrong game by name for user:", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
if v, err := games.GameState(ctx, id); err != nil {
|
||||||
|
t.Error("err getting game state:", err)
|
||||||
|
} else if len(v.Players) != 4 || !v.Completed.IsZero() {
|
||||||
|
t.Error("wrong game state:", v)
|
||||||
|
} else {
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
p := fmt.Sprintf("p%d", i+1)
|
||||||
|
if v.Players[p].KillWords.Global.Word == "" {
|
||||||
|
t.Error(p, "no killwords.global")
|
||||||
|
} else if v.Players[p].KillWords.Assigned.IsZero() {
|
||||||
|
t.Error(p, "no killwords.assigned")
|
||||||
|
} else if v.Players[p].KillWords.Assignee == "" {
|
||||||
|
t.Error(p, "no killwords.assignee")
|
||||||
|
} else if len(v.Players[p].KillWords.Assignment.Public) == 0 {
|
||||||
|
t.Error(p, "no killwords.assigment.public")
|
||||||
|
} else if len(v.Players[p].KillWords.Assignment.Private) == 0 {
|
||||||
|
t.Error(p, "no killwords.assigment.private")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := games.CreateEventGameComplete(ctx, id); err != nil {
|
||||||
|
t.Fatal("err creating game complete:", err)
|
||||||
|
} else if state, err := games.GameState(ctx, id); err != nil {
|
||||||
|
t.Fatal("err fetching state after completing:", err)
|
||||||
|
} else if state.Completed.IsZero() {
|
||||||
|
t.Fatal("state.Completed is zero")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
100
cmd/server/holiday.txt
Normal file
100
cmd/server/holiday.txt
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
Advent
|
||||||
|
Angels
|
||||||
|
Announcement
|
||||||
|
Bells
|
||||||
|
Bethlehem
|
||||||
|
Blitzen
|
||||||
|
Candles
|
||||||
|
Candy
|
||||||
|
Candy canes
|
||||||
|
Cards
|
||||||
|
Cedar
|
||||||
|
Celebrate
|
||||||
|
Ceremonies
|
||||||
|
Chimney
|
||||||
|
Christmas cookies
|
||||||
|
Christmas tree
|
||||||
|
Cold
|
||||||
|
Comet
|
||||||
|
Cranberry sauce
|
||||||
|
Crowds
|
||||||
|
Cupid
|
||||||
|
Dancer
|
||||||
|
Dasher
|
||||||
|
December
|
||||||
|
Decorations
|
||||||
|
Dolls
|
||||||
|
Donner
|
||||||
|
Dressing
|
||||||
|
Eggnog
|
||||||
|
Elves
|
||||||
|
Family reunion
|
||||||
|
Festival
|
||||||
|
Fir
|
||||||
|
Frosty
|
||||||
|
Fruitcake
|
||||||
|
Gift boxes
|
||||||
|
Gifts
|
||||||
|
Goodwill
|
||||||
|
Greetings
|
||||||
|
Ham
|
||||||
|
Happy
|
||||||
|
Holiday
|
||||||
|
Holly
|
||||||
|
Holy
|
||||||
|
Icicles
|
||||||
|
Jolly
|
||||||
|
Lights
|
||||||
|
Lists
|
||||||
|
Merry
|
||||||
|
Miracle
|
||||||
|
Mistletoe
|
||||||
|
New Year
|
||||||
|
Noel
|
||||||
|
North Pole
|
||||||
|
Pageant
|
||||||
|
Parades
|
||||||
|
Party
|
||||||
|
Pie
|
||||||
|
Pine
|
||||||
|
Plum pudding
|
||||||
|
Poinsettia
|
||||||
|
Prancer
|
||||||
|
Presents
|
||||||
|
Pumpkin pie
|
||||||
|
Punch
|
||||||
|
Red/green
|
||||||
|
Reindeer
|
||||||
|
Ribbon
|
||||||
|
Rudolph
|
||||||
|
Sacred
|
||||||
|
Sales
|
||||||
|
Sauce
|
||||||
|
Scrooge
|
||||||
|
Season
|
||||||
|
Sled
|
||||||
|
Sleighbells
|
||||||
|
Snowflakes
|
||||||
|
Spirit
|
||||||
|
St. Nick
|
||||||
|
Stand
|
||||||
|
Star
|
||||||
|
Stickers
|
||||||
|
Stocking stuffers
|
||||||
|
Sweet potato
|
||||||
|
Tidings
|
||||||
|
Tinsel
|
||||||
|
Togetherness
|
||||||
|
Toys
|
||||||
|
Tradition
|
||||||
|
Traffic
|
||||||
|
Trips
|
||||||
|
Turkey
|
||||||
|
Vacation
|
||||||
|
Vixen
|
||||||
|
Winter
|
||||||
|
Worship
|
||||||
|
Wrapping paper
|
||||||
|
Wreath
|
||||||
|
Yule
|
||||||
|
Yuletide
|
||||||
189
cmd/server/ws.go
189
cmd/server/ws.go
@@ -1,9 +1,16 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/coder/websocket"
|
"github.com/coder/websocket"
|
||||||
)
|
)
|
||||||
@@ -12,18 +19,190 @@ func isWS(r *http.Request) bool {
|
|||||||
return r.URL.Path == "/ws" || strings.HasPrefix(r.URL.Path, "/ws/")
|
return r.URL.Path == "/ws" || strings.HasPrefix(r.URL.Path, "/ws/")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *S) serveWS(httpw http.ResponseWriter, httpr *http.Request) error {
|
func (s *S) serveWS(w http.ResponseWriter, r *http.Request) error {
|
||||||
ctx := httpr.Context()
|
ctx, can := context.WithCancel(r.Context())
|
||||||
|
defer can()
|
||||||
|
r = r.WithContext(ctx)
|
||||||
|
|
||||||
c, err := websocket.Accept(httpw, httpr, nil)
|
session := s.Session(ctx)
|
||||||
|
games, err := s.games.GamesForUser(ctx, session.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(games) == 0 {
|
||||||
|
return fmt.Errorf("user %s is in zero games", session.ID)
|
||||||
|
}
|
||||||
|
game := games[0]
|
||||||
|
|
||||||
|
c, err := websocket.Accept(w, r, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer c.CloseNow()
|
defer c.CloseNow()
|
||||||
|
|
||||||
if err := c.Write(ctx, 1, []byte("hello world")); err != nil {
|
go func() {
|
||||||
|
defer can()
|
||||||
|
for {
|
||||||
|
_, b, err := c.Read(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("READ %s", b)
|
||||||
|
|
||||||
|
var m map[string]string
|
||||||
|
if err := json.Unmarshal(b, &m); err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("UNMARSHAL %+v", m)
|
||||||
|
|
||||||
|
if m["party"] == "start" {
|
||||||
|
if gameState, err := s.games.GameState(ctx, game); err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
return
|
||||||
|
} else if gameState.Started {
|
||||||
|
} else if err := s.games.CreateEventAssignmentRotation(ctx, game, "", "", "", 0); err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if m["k"] != "" {
|
||||||
|
log.Println("TODO a kill occurred")
|
||||||
|
return
|
||||||
|
} else if name := m["name"]; name != "" {
|
||||||
|
if err := s.games.UpdateUserName(ctx, s.Session(ctx).ID, name); err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if m["again"] == "true" {
|
||||||
|
if gameState, err := s.games.GameState(ctx, game); err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
return
|
||||||
|
} else if gameState.Completed.IsZero() {
|
||||||
|
} else {
|
||||||
|
log.Println("TODO new game")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Printf("UNKNOWN: %+v", m)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-time.After(time.Second * 1):
|
||||||
|
}
|
||||||
|
|
||||||
|
gameState, err := s.games.GameState(ctx, game)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Errorf("not impl")
|
msg := map[string]any{
|
||||||
|
"help": strings.Join([]string{
|
||||||
|
"CARD ASSASSINS (Mobile Ed.)",
|
||||||
|
"",
|
||||||
|
"1. Get any target to say any of his or her kill words.",
|
||||||
|
"2. Click on the kill word a target said.",
|
||||||
|
"",
|
||||||
|
"The game ends when everyone has been assassinated.",
|
||||||
|
}, "<br>"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if gameState.Started {
|
||||||
|
msg["page"] = "B"
|
||||||
|
if gameState.Completed.IsZero() {
|
||||||
|
msg["event"] = "A"
|
||||||
|
items := []map[string]any{}
|
||||||
|
for k, v := range gameState.Players {
|
||||||
|
if k == s.Session(ctx).ID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
name, err := s.games.UserName(ctx, k)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
tags := []map[string]any{}
|
||||||
|
if self := gameState.Players[s.Session(ctx).ID]; self.KillWords.Assignee == k {
|
||||||
|
for _, private := range v.KillWords.Assignment.Private {
|
||||||
|
tags = append(tags, map[string]any{
|
||||||
|
"k": private.Word,
|
||||||
|
"v": private.Points,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, public := range v.KillWords.Assignment.Public {
|
||||||
|
tags = append(tags, map[string]any{
|
||||||
|
"k": public.Word,
|
||||||
|
"v": public.Points,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if self := gameState.Players[s.Session(ctx).ID]; !slices.ContainsFunc(self.Kills, func(a Kill) bool {
|
||||||
|
return a.Victim == k
|
||||||
|
}) {
|
||||||
|
tags = append(tags, map[string]any{
|
||||||
|
"k": self.KillWords.Global.Word,
|
||||||
|
"v": self.KillWords.Global.Points,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
items = append(items, map[string]any{
|
||||||
|
"name": name,
|
||||||
|
"title": strconv.Itoa(v.Points()),
|
||||||
|
"tags": tags,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
slices.SortFunc(items, func(a, b map[string]any) int {
|
||||||
|
an, _ := strconv.Atoi(fmt.Sprint(a["title"]))
|
||||||
|
bn, _ := strconv.Atoi(fmt.Sprint(b["title"]))
|
||||||
|
return an - bn
|
||||||
|
})
|
||||||
|
return io.EOF
|
||||||
|
} else {
|
||||||
|
msg["event"] = "B"
|
||||||
|
items := []map[string]any{}
|
||||||
|
for k, v := range gameState.Players {
|
||||||
|
name, err := s.games.UserName(ctx, k)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tags := []map[string]any{}
|
||||||
|
for _, kill := range v.Kills {
|
||||||
|
tags = append(tags, map[string]any{
|
||||||
|
"k": kill.KillWord.Word,
|
||||||
|
"v": kill.Victim,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
items = append(items, map[string]any{
|
||||||
|
"name": name,
|
||||||
|
"title": fmt.Sprint(v.Points()),
|
||||||
|
"tags": tags,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
msg["items"] = items
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
msg["page"] = "A"
|
||||||
|
items := []map[string]any{}
|
||||||
|
for k := range gameState.Players {
|
||||||
|
name, err := s.games.UserName(ctx, k)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
items = append(items, map[string]any{"name": name})
|
||||||
|
}
|
||||||
|
msg["items"] = items
|
||||||
|
}
|
||||||
|
|
||||||
|
msgB, _ := json.Marshal(msg)
|
||||||
|
if err := c.Write(ctx, 1, msgB); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -9,7 +9,7 @@ require (
|
|||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||||
github.com/glebarez/sqlite v1.11.0 // indirect
|
github.com/glebarez/sqlite v1.11.0 // indirect
|
||||||
github.com/google/uuid v1.3.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.17 // indirect
|
github.com/mattn/go-isatty v0.0.17 // indirect
|
||||||
|
|||||||
2
go.sum
2
go.sum
@@ -8,6 +8,8 @@ github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GM
|
|||||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
|
|||||||
12
todo.yaml
Executable file
12
todo.yaml
Executable file
@@ -0,0 +1,12 @@
|
|||||||
|
todo:
|
||||||
|
- refactor ws.go into like WebSocketSession struct and stuff
|
||||||
|
- test ws flow
|
||||||
|
- notifications system with dismissal server-side so users see X got a kill
|
||||||
|
- play mp3 on kill + shuffle
|
||||||
|
- end condition; everyone has died
|
||||||
|
- word lists; already got holidays
|
||||||
|
- event driven woulda been nice
|
||||||
|
- remake
|
||||||
|
- quit
|
||||||
|
scheduled: []
|
||||||
|
done: []
|
||||||
Reference in New Issue
Block a user