Compare commits

..

9 Commits

Author SHA1 Message Date
bel
fbf512c0d2 create event assignment rotation TODO 2024-12-15 00:05:24 -07:00
bel
2d87e78556 stub create events 2024-12-14 23:48:59 -07:00
bel
aadc23c45b assignmentRotation impl 2024-12-14 23:45:47 -07:00
bel
d597a74a2b gitignore 2024-12-14 23:41:28 -07:00
bel
0bade0bd4f todo last event is big one 2024-12-14 23:25:08 -07:00
bel
85fdfac888 games inits itself 2024-12-14 21:37:57 -07:00
bel
d3689e36a7 games struct stubbed 2024-12-14 21:36:08 -07:00
bel
91357afb1f locking 2024-12-14 21:32:39 -07:00
bel
73e79212a9 db.Query and db.Exec 2024-12-14 21:19:04 -07:00
20 changed files with 105 additions and 2478 deletions

2
.gitignore vendored
View File

@@ -1,3 +1 @@
/cmd/server/server /cmd/server/server
**/exec-*
**/.*.sw*

BIN
cmd/server/.games.go.swp Normal file

Binary file not shown.

View File

@@ -1,249 +0,0 @@
amazing
astounding
adorable
astonishing
awesome
agreeable
admirable
affectionate
artistic
animated
beautiful
brave
brilliant
big
bouncy
bright
busy
bold
blissful
bubbly
caring
clever
courageous
creative
cute
cheerful
charming
confident
curious
content
daring
delightful
determined
diligent
dynamic
decisive
dazzling
dependable
dramatic
dreamy
eager
earnest
effervescent
elegant
energetic
enchanting
enthusiastic
excellent
exciting
expressive
fair
faithful
fantastic
fearless
festive
friendly
funny
feisty
forgiving
focused
gentle
giving
glad
gleeful
glorious
good
graceful
grateful
great
gregarious
happy
helpful
honest
hopeful
hungry
hilarious
honorable
huggable
humble
hardworking
important
impressive
incredible
independent
inquisitive
intelligent
interesting
imaginative
inventive
inspiring
joyful
jolly
jovial
judicious
just
jaunty
jubilant
jazzy
jumpy
joking
kind
knowledgeable
keen
kooky
kindhearted
karate-chopping
krazy
kicking
kissable
kidding
loving
laughing
likable
lucky
lovely
light
little
loud
lazy
lanky
merry
magical
marvellous
mysterious
mischievous
masterful
mindful
melodic
mighty
motivated
nice
nimble
nifty
noisy
nutty
nautical
noteworthy
nosey
neat
nourished
odd
old
obedient
outstanding
opinionated
optimistic
orderly
outgoing
overjoyed
organized
perfect
playful
pleasant
polite
powerful
peaceful
patient
proud
puzzled
perky
quirky
quick
queenly
quaint
qualified
quizzical
quaint
quiet
quirky
quacking
rainy
rambunctious
respectful
right
responsible
ripe
rustic
rotten
rhythmic
righteous
silly
sweet
smart
smiling
strong
super
skillful
sleepy
scented
spotless
thankful
tired
tasty
talented
thoughtful
tremendous
terrific
truthful
tough
trustworthy
unique
understanding
unusual
upbeat
useful
uplifting
unafraid
universal
unlimited
unselfish
victorious
vivacious
valuable
vibrant
versatile
virtuous
visionary
vocal
vivacious
valiant
wonderful
whimsical
welcoming
witty
wise
wild
warm
wacky
willing
watchful
xenial
xeric
yummy
yellow
yippee
yappy
young
yucky
yummy
youthful
yakky
zany
zesty
zen
zealous
zingy
zippy
zonal
zonked

View File

@@ -1,100 +0,0 @@
lion
tiger
goat
horse
donkey
dog
cat
pig
cow
elephant
giraffe
kangaroo
koala
deer
moose
sheep
zebra
bear
wolf
fox
otter
raccoon
squirrel
bat
chimpanzee
gorilla
orangutan
lemur
panda
red panda
hippopotamus
rhinoceros
camel
llama
alpaca
ferret
hedgehog
skunk
beaver
walrus
seal
dolphin
whale
bat
eagle
hawk
falcon
owl
parrot
crow
raven
pigeon
dove
swan
goose
duck
chicken
turkey
peacock
ostrich
emu
snake
lizard
turtle
crocodile
alligator
chameleon
gecko
shark
dolphin
whale
octopus
lobster
crab
shrimp
clam
oyster
ant
bee
butterfly
caterpillar
cricket
grasshopper
ladybug
mosquito
scorpion
spider
worm
snail
jellyfish
starfish
sponge
platypus
koala
tasmanian devil
kangaroo
wombat
wallaby
meerkat
lemming

View File

@@ -3,6 +3,7 @@ package main
import ( import (
"context" "context"
"database/sql" "database/sql"
"io"
"sync" "sync"
"time" "time"
@@ -13,19 +14,16 @@ 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)
@@ -40,18 +38,12 @@ 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...)
} }
@@ -67,10 +59,8 @@ 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...)
} }
@@ -106,3 +96,7 @@ 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
}

View File

@@ -1,95 +0,0 @@
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)
}
})
}

View File

@@ -3,17 +3,11 @@ package main
import ( import (
"context" "context"
"database/sql" "database/sql"
_ "embed"
"encoding/json" "encoding/json"
"fmt" "fmt"
"maps" "io"
"math/rand"
"slices" "slices"
"strings"
"time" "time"
"unicode"
"github.com/google/uuid"
) )
type Games struct { type Games struct {
@@ -57,126 +51,19 @@ func (games Games) GamesForUser(ctx context.Context, id string) ([]string, error
}, ` }, `
SELECT players.game_uuid SELECT players.game_uuid
FROM players FROM players
WHERE players.user_uuid=? WHERE players.user_uuid=?
`, id)
return result, err
}
func (games Games) UpdateUserName(ctx context.Context, id, name string) error {
var n int
if err := games.db.Query(ctx, func(rows *sql.Rows) error {
return rows.Scan(&n)
}, `SELECT COUNT(uuid) FROM users WHERE uuid=?`, id); err != nil {
return err
}
if n > 0 {
return games.db.Exec(ctx, `UPDATE users SET name=? WHERE uuid=?`, name, id)
}
return games.db.Exec(ctx, `INSERT INTO users (uuid, name) VALUES (?, ?)`, id, name)
}
//go:embed adjectives.txt
var namesAdjectives string
//go:embed animals.txt
var namesAnimals string
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)
if result == "" {
adjectives := strings.Fields(namesAdjectives)
animals := strings.Split(namesAnimals, "\n")
animals = slices.DeleteFunc(animals, func(s string) bool { return s == "" })
name := strings.Title(fmt.Sprintf("%s %s", adjectives[rand.Intn(len(adjectives))], animals[rand.Intn(len(animals))]))
if err := games.UpdateUserName(ctx, id, name); err != nil {
return "", err
}
return games.UserName(ctx, id)
}
return result, err
}
func (games Games) UserByName(ctx context.Context, gid, name string) (string, error) {
result := ""
err := games.db.Query(ctx, func(rows *sql.Rows) error {
return rows.Scan(&result)
}, `
SELECT users.uuid
FROM players
JOIN users ON players.user_uuid=users.uuid
WHERE players.game_uuid=? AND users.name=?
`, gid, name)
return result, err
}
func (a KillWord) Empty() bool {
return a == (KillWord{})
}
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) GameName(ctx context.Context, id string) (string, error) {
var result string
err := games.db.Query(ctx, func(rows *sql.Rows) error {
return rows.Scan(&result)
}, `
SELECT
games.name
FROM
games
WHERE games.uuid=?
ORDER BY games.updated DESC
LIMIT 1
`, id) `, id)
return result, err return result, err
} }
func (games Games) GameByName(ctx context.Context, name string) (string, error) { func (games Games) GameByName(ctx context.Context, name string) (string, error) {
var result string return "", io.EOF
err := games.db.Query(ctx, func(rows *sql.Rows) error {
return rows.Scan(&result)
}, `
SELECT
games.uuid
FROM
games
WHERE games.name=?
ORDER BY games.updated DESC
LIMIT 1
`, name)
return result, err
} }
type ( type (
GameState struct { GameState struct {
ID string
Started bool
Completed time.Time Completed time.Time
Players map[string]PlayerState
Players map[string]PlayerState
Trial Trial
} }
PlayerState struct { PlayerState struct {
@@ -187,11 +74,11 @@ type (
Kill struct { Kill struct {
Timestamp time.Time Timestamp time.Time
Victim string Victim string
KillWord KillWord Public bool
} }
KillWords struct { KillWords struct {
Codename Codename Global string
Assigned time.Time Assigned time.Time
Assignee string Assignee string
@@ -199,75 +86,26 @@ type (
Assignment Assignment Assignment Assignment
} }
Codename struct {
KillWord KillWord
Consumed bool
}
Assignment struct { Assignment struct {
Public []KillWord Public []string
Private []KillWord Private []string
}
KillWord struct {
Word string
Points int
}
Trial struct {
Prosecutor string
Defendant string
Word string
} }
EventType int EventType int
EventPlayerJoin struct { EventPlayerJoin struct {
Type EventType ID string
Timestamp time.Time
ID string
} }
EventPlayerLeave struct { EventPlayerLeave struct {
Type EventType ID string
Timestamp time.Time
ID string
}
EventGameComplete struct {
Type EventType
Timestamp time.Time
} }
EventGameComplete struct{}
EventAssignmentRotation struct { EventAssignmentRotation struct {
Type EventType Killer string
Timestamp time.Time Killed string
Killer string KillWord string
Victim string KillWords map[string]KillWords
KillWord KillWord
AllKillWords AllKillWords
} }
EventGameReset struct {
Type EventType
Timestamp time.Time
ID string
}
EventCodenameAccusal struct {
Type EventType
Timestamp time.Time
Prosecutor string
Defendant string
Word string
}
EventCodenameTrial struct {
Type EventType
Timestamp time.Time
Guilty bool
}
EventNotification struct {
Type EventType
Timestamp time.Time
Recipient string
Message string
}
AllKillWords map[string]KillWords
) )
const ( const (
@@ -275,146 +113,60 @@ const (
PlayerLeave PlayerLeave
GameComplete GameComplete
AssignmentRotation AssignmentRotation
GameReset
CodenameAccusal
CodenameTrial
Notification
) )
type Event interface{ event() } func (games Games) GameState(ctx context.Context, id string) (GameState, error) {
result := GameState{Players: map[string]PlayerState{}}
func (EventPlayerJoin) event() {}
func (EventPlayerLeave) event() {}
func (EventGameComplete) event() {}
func (EventAssignmentRotation) event() {}
func (EventGameReset) event() {}
func (EventCodenameAccusal) event() {}
func (EventCodenameTrial) event() {}
func (EventNotification) 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
case EventGameReset:
e.Timestamp = t
event = e
case EventCodenameAccusal:
e.Timestamp = t
event = e
case EventCodenameTrial:
e.Timestamp = t
event = e
case EventNotification:
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 { err := games.db.Query(ctx, func(rows *sql.Rows) error {
var timestamp time.Time var timestamp time.Time
var b []byte var payload []byte
if err := rows.Scan(&timestamp, &b); err != nil { if err := rows.Scan(&timestamp, &payload); err != nil {
return err 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 {
var peek struct { Type EventType
Type EventType }
} if err := json.Unmarshal(payload, &peek); err != nil {
if err := json.Unmarshal(b, &peek); err != nil { return err
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
case GameReset:
var v EventGameReset
err := json.Unmarshal(b, &v)
return EventWithTime(v, timestamp), err
case CodenameAccusal:
var v EventCodenameAccusal
err := json.Unmarshal(b, &v)
return EventWithTime(v, timestamp), err
case CodenameTrial:
var v EventCodenameTrial
err := json.Unmarshal(b, &v)
return EventWithTime(v, timestamp), err
case Notification:
var v EventNotification
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) { switch peek.Type {
result := GameState{ID: id, Players: map[string]PlayerState{}} case PlayerJoin:
var playerJoin EventPlayerJoin
events, err := games.GameEvents(ctx, id, time.Time{}) if err := json.Unmarshal(payload, &playerJoin); err != nil {
if err != nil { return err
return result, err }
} result.Players[playerJoin.ID] = PlayerState{}
return nil
for _, event := range events { case PlayerLeave:
switch e := event.(type) { var playerLeave EventPlayerLeave
case EventPlayerJoin: if err := json.Unmarshal(payload, &playerLeave); err != nil {
result.Players[e.ID] = PlayerState{} return err
case EventPlayerLeave: }
delete(result.Players, e.ID) delete(result.Players, playerLeave.ID)
case EventGameComplete: return nil
result.Completed = e.Timestamp case GameComplete:
case EventAssignmentRotation: var gameComplete EventGameComplete
result.Started = true if err := json.Unmarshal(payload, &gameComplete); err != nil {
assignmentRotation := e 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 { if killer, ok := result.Players[assignmentRotation.Killer]; !ok {
} else if _, ok := result.Players[assignmentRotation.Victim]; !ok { } else if victim, ok := result.Players[assignmentRotation.Killed]; !ok {
} else { } else {
killer.Kills = append(killer.Kills, Kill{ killer.Kills = append(killer.Kills, Kill{
Timestamp: assignmentRotation.Timestamp, Timestamp: timestamp,
Victim: assignmentRotation.Victim, Victim: assignmentRotation.Killed,
KillWord: assignmentRotation.KillWord, Public: slices.Contains(victim.KillWords.Assignment.Public, assignmentRotation.KillWord),
}) })
result.Players[assignmentRotation.Killer] = killer result.Players[assignmentRotation.Killer] = killer
} }
@@ -424,419 +176,56 @@ func (games Games) GameState(ctx context.Context, id string) (GameState, error)
result.Players[k] = v result.Players[k] = v
} }
for k, v := range assignmentRotation.AllKillWords { for k, v := range assignmentRotation.KillWords {
player := result.Players[k] player := result.Players[k]
player.KillWords = v player.KillWords = v
result.Players[k] = player result.Players[k] = player
} }
case EventCodenameAccusal: return nil
if actual := result.Players[e.Defendant].KillWords.Codename; !actual.Consumed {
result.Trial.Prosecutor = e.Prosecutor
result.Trial.Defendant = e.Defendant
result.Trial.Word = e.Word
if !basicallyTheSame(actual.KillWord.Word, e.Word) {
} else if err := games.CreateEventCodenameTrial(ctx, id, true); err != nil { // TODO cannot be in State loop
return GameState{}, err
}
}
case EventCodenameTrial:
if result.Trial == (Trial{}) {
} else if e.Guilty {
if err := games.CreateEventNotification(ctx, id, fmt.Sprintf(`%s revealed %s is %s and collected %s's bounty.`, result.Trial.Prosecutor, result.Trial.Defendant, result.Trial.Word, result.Trial.Defendant)); err != nil { // TODO not in this loop
return GameState{}, err
}
return GameState{}, fmt.Errorf("not impl: trial: guilty: %+v", e)
} else {
v := result.Players[result.Trial.Prosecutor]
v.KillWords.Codename.Consumed = true
v.Kills = append(v.Kills, Kill{
Timestamp: e.Timestamp,
Victim: result.Trial.Defendant,
KillWord: KillWord{
Word: result.Trial.Word,
Points: -200,
},
})
result.Players[result.Trial.Prosecutor] = v
v = result.Players[result.Trial.Defendant]
v.KillWords.Codename.KillWord.Word = "" // TODO
return GameState{}, fmt.Errorf("creating state CANNOT create events because it will eval every loop")
if err := games.CreateEventNotification(ctx, id, fmt.Sprintf(`%s accused the innocent %s of being %s. %s will get a new codename.`, result.Trial.Prosecutor, result.Trial.Defendant, result.Trial.Word, result.Trial.Defendant)); err != nil {
return GameState{}, err
}
}
result.Trial = Trial{}
case EventGameReset:
return games.GameState(ctx, e.ID)
default: default:
return GameState{}, fmt.Errorf("unknown event type %T", event) 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 return result, err
} }
func basicallyTheSame(a, b string) bool {
simplify := func(s string) string {
s = strings.TrimSpace(strings.ToLower(s))
s2 := ""
for _, c := range s {
if unicode.IsLetter(c) {
s2 = fmt.Sprintf("%s%c", s2, c)
}
}
return s2
}
return simplify(a) == simplify(b)
}
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 { func (games Games) CreateEventPlayerJoin(ctx context.Context, id string, player string) error {
if _, err := games.UserName(ctx, player); err != nil { return games.createEvent(ctx, id, EventPlayerJoin{ID: player})
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{Type: PlayerLeave, ID: player}) return games.createEvent(ctx, id, EventPlayerLeave{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 := state.AllKillWords()
event.AllKillWords = prevAllKillWords.ShuffleAssignees(killer, victim, word)
event.AllKillWords = event.AllKillWords.FillKillWords()
return games.createEvent(ctx, id, event)
}
func (state GameState) AllKillWords() AllKillWords {
m := make(AllKillWords)
for k, v := range state.Players {
m[k] = v.KillWords
}
return m
}
func (games Games) CreateEventGameReset(ctx context.Context, gid string) error {
state, err := games.GameState(ctx, gid)
if err != nil {
return err
}
name, err := games.GameName(ctx, gid)
if err != nil {
return err
}
gid2, err := games.CreateGame(ctx, name)
if err != nil {
return err
}
for p := range state.Players {
if err := games.CreateEventPlayerJoin(ctx, gid2, p); err != nil {
return err
}
}
event := EventGameReset{
Type: GameReset,
Timestamp: time.Now(),
ID: gid2,
}
return games.createEvent(ctx, gid, event)
}
func (words KillWords) Empty() bool {
return words.Codename == (Codename{}) && 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 if killer != victimState.Assignee { // if victim was targeting killer, just randomize
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{
Codename: m[k].Codename,
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(
poolCodename []string,
nPublic int,
poolPublic []string,
nPrivate int,
poolPrivate []string,
) AllKillWords {
result := maps.Clone(m)
m = result
for k, v := range m {
if v.Codename.KillWord.Word == "" {
v.Codename = Codename{KillWord: KillWord{Word: m.unusedCodename(poolCodename), Points: 200}}
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) unusedCodename(pool []string) string {
inUse := func() []string {
result := []string{}
for _, killWords := range m {
result = append(result, killWords.Codename.KillWord.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 { func (games Games) CreateEventGameComplete(ctx context.Context, id string) error {
if err := games.db.Exec(ctx, ` return games.createEvent(ctx, id, EventGameComplete{})
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) CreateEventCodenameAccusal(ctx context.Context, gid, prosecutor, defendant, codename string) error { func (games Games) CreateEventAssignmentRotation(ctx context.Context, id string, killer, killed, killWord string) error {
return fmt.Errorf("not impl: x accused y") // TODO gather current assignees
return fmt.Errorf("not impl: x caught by y") // 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
func (games Games) CreateEventCodenameTrial(ctx context.Context, gid string, guilty bool) error { return io.EOF
return fmt.Errorf("not impl: x found guilty/notguilty") return games.createEvent(ctx, id, v)
}
func (games Games) CreateEventNotification(ctx context.Context, gid, msg string) error {
return games.CreateEventNotificationTo(ctx, gid, "", msg)
}
func (games Games) CreateEventNotificationTo(ctx context.Context, gid, uid, msg string) error {
return fmt.Errorf("not impl: simple")
} }
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(v) payload, err := json.Marshal(any)
if err != nil { if err != nil {
panic(err) panic(err)
} }
return games.db.Exec(ctx, ` return games.db.Exec(ctx, `
INSERT INTO events ( INSERT INTO events (
game_uuid, game_uuid,
timestamp, timestamp,
payload payload
) VALUES (?, ?, ?) ) VALUES (?, ?, ?)

View File

@@ -1,438 +0,0 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"testing"
"time"
)
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"); 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); 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 wrong", name)
}
if err := games.UpdateUserName(ctx, p, "player! "+p); err != nil {
t.Fatal(p, "failed to rename:", err)
} else if name, err := games.UserName(ctx, p); err != nil {
t.Fatal(p, "err getting user name", err)
} else if name != "player! "+p {
t.Fatal("updated name wrong", name)
}
}
if events, err := games.GameEvents(ctx, id, time.Time{}); err != nil {
t.Fatal("failed to get player join, leave events:", err)
} else if len(events) != 6 {
t.Error("wrong number of events:", len(events))
}
now := time.Now()
if err := games.CreateEventAssignmentRotation(ctx, id, "", "", "", 1); err != nil {
t.Fatal("err creating rotation:", err)
}
if events, err := games.GameEvents(ctx, id, time.Time{}); err != nil {
t.Fatal("failed to get player join, leave events:", err)
} else if len(events) != 7 {
t.Error("wrong number of events:", len(events))
} else if events, err = games.GameEvents(ctx, id, now); err != nil {
t.Fatal("failed to get assignment rotation event:", err)
} else if len(events) != 1 {
t.Error("wrong number of events:", len(events))
} else if _, ok := events[0].(EventAssignmentRotation); !ok {
t.Errorf("not an assignment rotation event: %T", events[0])
}
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, "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].Points() != 0 {
t.Error("nonzero points after zero kills:", v.Players[p].Points())
}
if v.Players[p].KillWords.Codename.KillWord.Word == "" {
t.Error(p, "no killwords.Codename")
} 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")
}
if err := games.CreateEventGameReset(ctx, id); err != nil {
t.Fatal(err)
} else if state, err := games.GameState(ctx, id); err != nil {
t.Fatal(err)
} else if state.ID == id {
t.Fatal("getting state for reset game didnt return state for new game")
} else if state.Started {
t.Fatal("reset game is started", state.Started)
} else if !state.Completed.IsZero() {
t.Fatal("reset game is complete", state.Completed)
} else if len(state.Players) != 4 {
t.Fatal("reset game doesnt have all players", len(state.Players))
} else if p := state.Players["p1"]; !p.Empty() {
t.Fatal("reset game missing p1", p)
} else if p := state.Players["p2"]; !p.Empty() {
t.Fatal("reset game missing p2", p)
} else if p := state.Players["p3"]; !p.Empty() {
t.Fatal("reset game missing p3", p)
} else if p := state.Players["p4"]; !p.Empty() {
t.Fatal("reset game missing p4", p)
}
})
}
func TestParseEvent(t *testing.T) {
now := time.Now()
cases := map[string]Event{
"player join": EventPlayerJoin{
Type: PlayerJoin,
ID: "x",
},
"player leave": EventPlayerLeave{
Type: PlayerLeave,
ID: "x",
},
"game complete": EventGameComplete{
Type: GameComplete,
},
"assignment rotation": EventAssignmentRotation{
Type: AssignmentRotation,
Killer: "x",
Victim: "y",
KillWord: KillWord{
Word: "word",
Points: 1,
},
AllKillWords: map[string]KillWords{
"x": KillWords{
Codename: Codename{KillWord: KillWord{
Word: "a",
Points: 200,
}},
Assignee: "z",
Assigned: now,
Assignment: Assignment{
Public: []KillWord{{
Word: "word2",
Points: 2,
}},
Private: []KillWord{{
Word: "word3",
Points: 3,
}},
},
},
},
},
}
for name, d := range cases {
c := d
t.Run(name, func(t *testing.T) {
c := EventWithTime(c, now)
b, _ := json.Marshal(c)
got, err := parseEvent(b, now)
if err != nil {
t.Fatal(err)
}
gotb, _ := json.Marshal(got)
if string(b) != string(gotb) {
t.Errorf("expected (%T) %+v, but got (%T) %+v", c, c, got, got)
}
})
}
}
func TestAllKillWordsFill(t *testing.T) {
kw := func(p int, w string) KillWord {
return KillWord{Word: w, Points: p}
}
kws := func(points int, w string) []KillWord {
if w == "" {
return nil
}
return []KillWord{kw(points, w)}
}
ass := func(pub, pri string) Assignment {
return Assignment{
Public: kws(50, pub),
Private: kws(100, pri),
}
}
cases := map[string]struct {
given KillWords
expect KillWords
}{
"full": {
given: KillWords{
Codename: Codename{KillWord: kw(200, "global")},
Assignment: ass("pub", "pri"),
},
expect: KillWords{
Codename: Codename{KillWord: kw(200, "global")},
Assignment: ass("pub", "pri"),
},
},
"no ass": {
given: KillWords{
Codename: Codename{KillWord: kw(200, "global")},
Assignment: Assignment{},
},
expect: KillWords{
Codename: Codename{KillWord: kw(200, "global")},
Assignment: ass("filled-public", "filled-private"),
},
},
"no pub": {
given: KillWords{
Codename: Codename{KillWord: kw(200, "global")},
Assignment: ass("", "pri"),
},
expect: KillWords{
Codename: Codename{KillWord: kw(200, "global")},
Assignment: ass("filled-public", "pri"),
},
},
"no pri": {
given: KillWords{
Codename: Codename{KillWord: kw(200, "global")},
Assignment: ass("pub", ""),
},
expect: KillWords{
Codename: Codename{KillWord: kw(200, "global")},
Assignment: ass("pub", "filled-private"),
},
},
"empty": {
given: KillWords{},
expect: KillWords{
Codename: Codename{KillWord: kw(200, "filled-global")},
Assignment: ass("filled-public", "filled-private"),
},
},
"no global": {
given: KillWords{
Assignment: ass("pub", "pri"),
},
expect: KillWords{
Codename: Codename{KillWord: kw(200, "filled-global")},
Assignment: ass("pub", "pri"),
},
},
}
equal := func(a, b KillWords) bool {
ba, _ := json.Marshal(a)
bb, _ := json.Marshal(b)
return bytes.Equal(ba, bb)
}
for name, d := range cases {
c := d
t.Run(name, func(t *testing.T) {
akw := make(AllKillWords)
akw[name] = c.given
akw = akw.fillKillWords(
[]string{"filled-global"},
1,
[]string{"filled-public"},
1,
[]string{"filled-private"},
)
got := akw[name]
if !equal(c.expect, got) {
t.Errorf("expected \n\t%+v but got \n\t%+v", c.expect, got)
}
})
}
}
func TestAllKillWordsUnused(t *testing.T) {
t.Run("empty", func(t *testing.T) {
akw := make(AllKillWords)
if got := akw.unusedPublic([]string{"x"}); got != "x" {
t.Error("empty playerbase didnt think only option was unused")
}
if got := akw.unusedPrivate([]string{"x"}); got != "x" {
t.Error("empty playerbase didnt think only option was unused")
}
if got := akw.unusedCodename([]string{"x"}); got != "x" {
t.Error("empty playerbase didnt think only option was unused")
}
})
t.Run("dont return used", func(t *testing.T) {
t.Run("private", func(t *testing.T) {
akw := make(AllKillWords)
akw["k"] = KillWords{
Codename: Codename{KillWord: KillWord{Word: "x"}},
Assignment: Assignment{
Private: []KillWord{{}, {Word: "y"}},
Public: []KillWord{{}, {Word: "x"}},
},
}
got := akw.unusedPrivate([]string{"x", "y"})
if got != "x" {
t.Error("didnt return only unused option")
}
})
t.Run("global", func(t *testing.T) {
akw := make(AllKillWords)
akw["k"] = KillWords{
Codename: Codename{KillWord: KillWord{Word: "y"}},
Assignment: Assignment{
Private: []KillWord{{}, {Word: "x"}},
Public: []KillWord{{}, {Word: "x"}},
},
}
got := akw.unusedCodename([]string{"x", "y"})
if got != "x" {
t.Error("didnt return only unused option")
}
})
t.Run("public", func(t *testing.T) {
akw := make(AllKillWords)
akw["k"] = KillWords{
Codename: Codename{KillWord: KillWord{Word: "x"}},
Assignment: Assignment{
Private: []KillWord{{}, {Word: "x"}},
Public: []KillWord{{}, {Word: "y"}},
},
}
got := akw.unusedPublic([]string{"x", "y"})
if got != "x" {
t.Error("didnt return only unused option")
}
})
})
}
func TestGenerateUserName(t *testing.T) {
games := newTestGames(t)
name, err := games.UserName(context.Background(), "id")
if err != nil {
t.Fatal(err)
}
if name == "" {
t.Fatal(name)
}
name2, err := games.UserName(context.Background(), "id")
if err != nil {
t.Fatal(err)
}
if name2 != name {
t.Fatal(name2)
}
if err := games.CreateEventPlayerJoin(context.Background(), "gid", "id"); err != nil {
t.Fatal("err creating event player join:", err)
}
if id, err := games.UserByName(context.Background(), "gid", name); err != nil {
t.Fatal("err getting user by name:", err)
} else if id != "id" {
t.Fatal("getting user by name yielded wrong id:", id)
}
t.Log(name)
}

View File

@@ -1,100 +0,0 @@
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

View File

@@ -59,19 +59,13 @@ type Session struct {
} }
func (s *S) injectContext(w http.ResponseWriter, r *http.Request) error { func (s *S) injectContext(w http.ResponseWriter, r *http.Request) error {
id := r.Header.Get("uuid") id, err := r.Cookie("uuid")
if id == "" { if err != nil || id.Value == "" {
c, _ := r.Cookie("uuid")
if c != nil {
id = c.Value
}
}
if id == "" {
return io.EOF return io.EOF
} }
ctx := r.Context() ctx := r.Context()
ctx = context.WithValue(ctx, "session", Session{ ctx = context.WithValue(ctx, "session", Session{
ID: id, ID: id.Value,
}) })
*r = *r.WithContext(ctx) *r = *r.WithContext(ctx)
return nil return nil

View File

@@ -1,170 +0,0 @@
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"slices"
"time"
)
type UserGameServer struct {
ID string
Session Session
games Games
lastPoll time.Time
}
func NewUserGameServer(ctx context.Context, session Session, games Games) (*UserGameServer, error) {
ids, err := games.GamesForUser(ctx, session.ID)
if err != nil {
return nil, err
}
if len(ids) == 0 {
return nil, fmt.Errorf("user %s is in zero games", session.ID)
}
return &UserGameServer{
ID: ids[0],
Session: session,
games: games,
}, nil
}
func (ugs *UserGameServer) More(ctx context.Context) error {
defer func() {
ugs.lastPoll = time.Now()
}()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(time.Second * 1):
}
if events, err := ugs.games.GameEvents(ctx, ugs.ID, ugs.lastPoll); err != nil {
return err
} else if len(events) == 0 {
continue
}
return nil
}
}
func (ugs *UserGameServer) Listen(ctx context.Context, can context.CancelFunc, reader func(context.Context) ([]byte, error)) {
defer can()
if err := ugs.listen(ctx, reader); err != nil && ctx.Err() == nil {
log.Println(err)
}
}
func (ugs *UserGameServer) listen(ctx context.Context, reader func(context.Context) ([]byte, error)) error {
for ctx.Err() == nil {
b, err := reader(ctx)
if err != nil {
return err
}
var m map[string]string
if err := json.Unmarshal(b, &m); err != nil {
return err
}
if startGame := m["party"] == "start"; startGame {
if gameState, err := ugs.games.GameState(ctx, ugs.ID); err != nil {
return err
} else if gameState.Started {
} else if err := ugs.games.CreateEventAssignmentRotation(ctx, ugs.ID, "", "", "", 0); err != nil {
return err
}
} else if killOccurred := m["k"] != ""; killOccurred {
victimName := m["name"]
word := m["k"]
if word == "" {
return fmt.Errorf("expected .k")
}
killer := ugs.Session.ID
victim, err := ugs.games.UserByName(ctx, ugs.ID, victimName)
if err != nil {
return err
}
var points int
if gameState, err := ugs.games.GameState(ctx, ugs.ID); err != nil {
return err
} else if codename := gameState.Players[killer].KillWords.Codename.KillWord; codename.Word == word {
points = codename.Points
} else if matches := slices.DeleteFunc(gameState.Players[victim].KillWords.Publics(), func(kw KillWord) bool { return kw.Word != word }); len(matches) > 0 {
points = matches[0].Points
} else if matches := slices.DeleteFunc(gameState.Players[victim].KillWords.Privates(), func(kw KillWord) bool { return kw.Word != word }); len(matches) > 0 {
points = matches[0].Points
} else {
return fmt.Errorf("refusing unexpected .k")
}
if err := ugs.games.CreateEventAssignmentRotation(ctx, ugs.ID, killer, victim, word, points); err != nil {
return err
}
} else if isRename := m["name"] != ""; isRename {
if err := ugs.games.UpdateUserName(ctx, ugs.Session.ID, m["name"]); err != nil {
return err
}
} else if isRestart := m["again"] == "true"; isRestart {
if gameState, err := ugs.games.GameState(ctx, ugs.ID); err != nil {
return err
} else if gameState.Completed.IsZero() {
} else if err := ugs.games.CreateEventGameReset(ctx, ugs.ID); err != nil {
return err
}
} else {
return fmt.Errorf("UNKNOWN: %+v", m)
}
}
return ctx.Err()
}
type UserGameState GameState
func (ugs *UserGameServer) State(ctx context.Context) (UserGameState, error) {
gameState, err := ugs.games.GameState(ctx, ugs.ID)
if err != nil {
return UserGameState{}, err
}
ugs.ID = gameState.ID
if complete := !gameState.Completed.IsZero(); complete {
return UserGameState(gameState), nil
}
self := gameState.Players[ugs.Session.ID]
for i := range self.Kills {
self.Kills[i].KillWord.Points = 0
}
self.KillWords.Assignment.Public = nil
self.KillWords.Assignment.Private = nil
gameState.Players[ugs.Session.ID] = self
for k, v := range gameState.Players {
if isSelf := k == ugs.Session.ID; isSelf {
v.KillWords.Assignment = Assignment{}
} else {
v.KillWords.Codename = Codename{}
v.KillWords.Assignee = ""
for i := range v.Kills {
v.Kills[i].Victim = ""
v.Kills[i].KillWord.Word = ""
}
}
if assignedToSomeoneElse := self.KillWords.Assignee != k; assignedToSomeoneElse {
v.KillWords.Assignment.Private = v.KillWords.Assignment.Private[:0]
}
gameState.Players[k] = v
}
return UserGameState(gameState), nil
}

View File

@@ -1,291 +0,0 @@
package main
import (
"context"
"fmt"
"testing"
)
func TestUserGameServer(t *testing.T) {
ctx := context.Background()
games := newTestGames(t)
gid, err := games.CreateGame(ctx, "g1")
if err != nil {
t.Fatal(err)
}
pids := []string{}
for i := 0; i < 4; i++ {
pid := fmt.Sprintf("p%d", i+1)
if err := games.CreateEventPlayerJoin(ctx, gid, pid); err != nil {
t.Fatal(err)
}
pids = append(pids, pid)
}
ugs, err := NewUserGameServer(ctx, Session{ID: pids[0]}, games)
if err != nil {
t.Fatal(err)
}
t.Run("unstarted", func(t *testing.T) {
state, err := ugs.State(ctx)
if err != nil {
t.Fatal(err)
}
if state.Started {
t.Error("started after player joins only")
}
if !state.Completed.IsZero() {
t.Error("completed after player joins only")
}
for _, pid := range pids {
if p, ok := state.Players[pid]; !ok {
t.Error(pid, "not in players")
} else if !p.Empty() {
t.Error(pid, p)
}
}
})
if err := games.CreateEventAssignmentRotation(ctx, gid, "", "", "", 0); err != nil {
t.Fatal(err)
}
t.Run("just started", func(t *testing.T) {
state, err := ugs.State(ctx)
if err != nil {
t.Fatal(err)
}
if !state.Started {
t.Error("not started after assignment rotation")
}
if !state.Completed.IsZero() {
t.Error("completed after assignment rotation")
}
for _, pid := range pids {
p, ok := state.Players[pid]
if !ok {
t.Error(pid, "not in players")
} else if p.Empty() {
t.Error(pid, p)
} else if len(p.Kills) > 0 {
t.Error(pid, "has a kill")
} else if p.KillWords.Assigned.IsZero() {
t.Error("assigned is zero")
}
if isSelf := pid == ugs.Session.ID; isSelf {
if p.KillWords.Codename.KillWord.Word == "" || p.KillWords.Codename.KillWord.Points == 0 {
t.Error("self codename missing field")
}
if p.KillWords.Assignee == "" {
t.Error("assignee is empty")
}
if len(p.KillWords.Assignment.Public) > 0 {
t.Error("self knows its own public")
}
if len(p.KillWords.Assignment.Private) > 0 {
t.Error("self knows its own private")
}
} else {
if !p.KillWords.Codename.KillWord.Empty() {
t.Error("can see not self Codename")
}
if p.KillWords.Assignee != "" {
t.Error("can see other player's assignee")
}
if len(p.KillWords.Assignment.Public) == 0 {
t.Error("cannot see other player's public")
}
if state.Players[ugs.Session.ID].KillWords.Assignee == pid && len(p.KillWords.Assignment.Private) == 0 {
t.Error("cannot see assignee's private")
} else if state.Players[ugs.Session.ID].KillWords.Assignee != pid && len(p.KillWords.Assignment.Private) > 0 {
t.Error("can see not assignee's private")
}
}
}
})
for i := 0; i < 3; i++ {
state, err := games.GameState(ctx, ugs.ID)
if err != nil {
t.Fatal(err)
}
killer := ugs.Session.ID
if i > 0 {
killer = state.Players[killer].KillWords.Assignee
}
victim := state.Players[killer].KillWords.Assignee
word := state.Players[victim].KillWords.Assignment.Public[0].Word
points := state.Players[victim].KillWords.Assignment.Public[0].Points
if err := games.CreateEventAssignmentRotation(ctx, gid, killer, victim, word, points); err != nil {
t.Fatal(err)
}
}
t.Run("after kills", func(t *testing.T) {
state, err := ugs.State(ctx)
if err != nil {
t.Fatal(err)
}
if !state.Started {
t.Error("not started after kills")
}
if !state.Completed.IsZero() {
t.Error("completed after kills")
}
for _, pid := range pids {
p, ok := state.Players[pid]
if !ok {
t.Error(pid, "not in players")
} else if p.Empty() {
t.Error(pid, p)
} else if p.KillWords.Assigned.IsZero() {
t.Error("assigned is zero")
}
if isSelf := pid == ugs.Session.ID; isSelf {
if len(p.Kills) == 0 {
t.Error("self never got a kill")
} else if kill := p.Kills[0]; kill.Timestamp.IsZero() {
t.Errorf("kill has no timestamp")
} else if kill.Victim == "" {
t.Errorf("kill has no victim")
} else if kill.KillWord.Points != 0 {
t.Errorf("know points of own kill")
} else if kill.KillWord.Word == "" {
t.Errorf("dont know own kill word")
}
if p.KillWords.Codename.KillWord.Word == "" || p.KillWords.Codename.KillWord.Points == 0 {
t.Error("self Codename missing field")
}
if p.KillWords.Assignee == "" {
t.Error("assignee is empty")
}
if len(p.KillWords.Assignment.Public) > 0 {
t.Error("self knows its own public")
}
if len(p.KillWords.Assignment.Private) > 0 {
t.Error("self knows its own private")
}
} else {
if len(p.Kills) == 0 {
} else if kill := p.Kills[0]; kill.Timestamp.IsZero() {
t.Errorf("kill has no timestamp")
} else if kill.Victim != "" {
t.Errorf("know other's victim")
} else if kill.KillWord.Points == 0 {
t.Errorf("other's kill has no points")
} else if kill.KillWord.Word != "" {
t.Errorf("know other's kill word")
}
if !p.KillWords.Codename.KillWord.Empty() {
t.Error("can see not self Codename")
}
if p.KillWords.Assignee != "" {
t.Error("can see other player's assignee")
}
if len(p.KillWords.Assignment.Public) == 0 {
t.Error("cannot see other player's public")
}
if state.Players[ugs.Session.ID].KillWords.Assignee == pid && len(p.KillWords.Assignment.Private) == 0 {
t.Error("cannot see assignee's private")
} else if state.Players[ugs.Session.ID].KillWords.Assignee != pid && len(p.KillWords.Assignment.Private) > 0 {
t.Error("can see not assignee's private")
}
}
}
})
if err := games.CreateEventGameComplete(ctx, gid); err != nil {
t.Fatal(err)
}
t.Run("completed", func(t *testing.T) {
state, err := ugs.State(ctx)
if err != nil {
t.Fatal(err)
}
if !state.Started {
t.Error("not started after complete")
}
if state.Completed.IsZero() {
t.Error("not complete after complete")
}
for _, pid := range pids {
p, ok := state.Players[pid]
if !ok {
t.Error(pid, "not in players")
} else if p.Empty() {
t.Error(pid, p)
} else if p.KillWords.Assigned.IsZero() {
t.Error("assigned is zero")
}
if isSelf := pid == ugs.Session.ID; isSelf {
if len(p.Kills) == 0 {
t.Error("self never got a kill")
} else if kill := p.Kills[0]; kill.Timestamp.IsZero() {
t.Errorf("kill has no timestamp")
} else if kill.Victim == "" {
t.Errorf("kill has no victim")
} else if kill.KillWord.Points == 0 {
t.Errorf("dont know points of own kill at game review")
} else if kill.KillWord.Word == "" {
t.Errorf("dont know own kill word")
}
if p.KillWords.Codename.KillWord.Word == "" || p.KillWords.Codename.KillWord.Points == 0 {
t.Error("self Codename missing field")
}
if p.KillWords.Assignee == "" {
t.Error("assignee is empty")
}
if len(p.KillWords.Assignment.Public) == 0 {
t.Error("self doesnt know its own public after game")
}
if len(p.KillWords.Assignment.Private) == 0 {
t.Error("self doesnt know its own private after game")
}
} else {
if len(p.Kills) == 0 {
} else if kill := p.Kills[0]; kill.Timestamp.IsZero() {
t.Errorf("kill has no timestamp")
} else if kill.Victim == "" {
t.Errorf("cannot know other's victim")
} else if kill.KillWord.Points == 0 {
t.Errorf("other's kill has no points")
} else if kill.KillWord.Word == "" {
t.Errorf("dont know other's kill word")
}
if p.KillWords.Codename.KillWord.Empty() {
t.Error("cannot see not self Codename")
}
if p.KillWords.Assignee == "" {
t.Error("cannot see other player's assignee")
}
if len(p.KillWords.Assignment.Public) == 0 {
t.Error("cannot see other player's public")
}
if state.Players[ugs.Session.ID].KillWords.Assignee == pid && len(p.KillWords.Assignment.Private) == 0 {
t.Error("cannot see assignee's private")
} else if state.Players[ugs.Session.ID].KillWords.Assignee != pid && len(p.KillWords.Assignment.Private) == 0 {
t.Error("cannot see not assignee's private after game")
}
}
}
})
}

View File

@@ -1,12 +1,9 @@
package main package main
import ( import (
"bytes" "fmt"
"encoding/json"
"io"
"net/http" "net/http"
"path" "path"
"slices"
"strings" "strings"
) )
@@ -15,29 +12,8 @@ func isV1(r *http.Request) bool {
} }
func (s *S) serveV1(w http.ResponseWriter, r *http.Request) error { func (s *S) serveV1(w http.ResponseWriter, r *http.Request) error {
uid := s.Session(r.Context()).ID
switch path.Join(r.Method, r.URL.Path) { switch path.Join(r.Method, r.URL.Path) {
case "GET/v1/state/" + uid: case "PUT/v1/state/" + s.Session(r.Context()).ID + "/party":
name, err := s.games.UserName(r.Context(), uid)
if err != nil {
return err
}
gids, err := s.games.GamesForUser(r.Context(), uid)
if err != nil {
return err
}
msg := map[string]any{
"name": name,
}
if len(gids) > 0 {
party, err := s.games.GameName(r.Context(), gids[0])
if err != nil {
return err
}
msg["party"] = party
}
return json.NewEncoder(w).Encode(msg)
case "PUT/v1/state/" + uid + "/party":
return s.serveV1PutParty(w, r) return s.serveV1PutParty(w, r)
default: default:
http.NotFound(w, r) http.NotFound(w, r)
@@ -46,27 +22,5 @@ func (s *S) serveV1(w http.ResponseWriter, r *http.Request) error {
} }
func (s *S) serveV1PutParty(w http.ResponseWriter, r *http.Request) error { func (s *S) serveV1PutParty(w http.ResponseWriter, r *http.Request) error {
party, err := io.ReadAll(r.Body) return fmt.Errorf("not impl")
if err != nil {
return err
}
party = bytes.TrimSpace(party)
if len(party) == 0 {
return nil
}
gid, err := s.games.GameByName(r.Context(), string(party))
if err != nil {
return err
}
games, err := s.games.GamesForUser(r.Context(), gid)
if err != nil {
return err
}
if slices.Contains(games, gid) {
return nil
}
return s.games.CreateEventPlayerJoin(r.Context(), gid, s.Session(r.Context()).ID)
} }

View File

@@ -1,12 +1,8 @@
package main package main
import ( import (
"context"
"encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"slices"
"strconv"
"strings" "strings"
"github.com/coder/websocket" "github.com/coder/websocket"
@@ -16,203 +12,18 @@ 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/")
} }
type WS struct { func (s *S) serveWS(httpw http.ResponseWriter, httpr *http.Request) error {
*S ctx := httpr.Context()
c *websocket.Conn
}
func (s *S) serveWS(w http.ResponseWriter, r *http.Request) error { c, err := websocket.Accept(httpw, httpr, nil)
c, err := websocket.Accept(w, r, nil)
if err != nil { if err != nil {
return err return err
} }
defer c.CloseNow() defer c.CloseNow()
ws := WS{S: s, c: c}
return ws.Serve(w, r)
}
func (ws WS) Serve(w http.ResponseWriter, r *http.Request) error { if err := c.Write(ctx, 1, []byte("hello world")); err != nil {
ctx, can := context.WithCancel(r.Context())
defer can()
r = r.WithContext(ctx)
ugs, err := ws.newUserGameServer(ctx)
if err != nil {
return err return err
} }
go ugs.Listen(ctx, can, func(ctx context.Context) ([]byte, error) { return fmt.Errorf("not impl")
_, b, err := ws.c.Read(ctx)
return b, err
})
for ugs.More(ctx) == nil {
if err := ws.Push(ctx, ugs); err != nil {
return err
}
}
return ctx.Err()
}
func (ws WS) newUserGameServer(ctx context.Context) (*UserGameServer, error) {
return NewUserGameServer(ctx, ws.Session(ctx), ws.games)
}
func (ws WS) Push(ctx context.Context, ugs *UserGameServer) error {
gameState, err := ugs.State(ctx)
if err != nil {
return err
}
var msg map[string]any
if unstarted := !gameState.Started; unstarted {
msg, err = ws.unstartedMsg(ctx, ugs, gameState)
} else if complete := !gameState.Completed.IsZero(); complete {
msg, err = ws.completeMsg(ctx, gameState)
} else {
msg, err = ws.inProgressMsg(ctx, ugs, gameState)
}
if err != nil {
return err
}
msg["help"] = strings.Join([]string{
"CARD ASSASSINS (Mobile Ed.)",
"",
"1. Get any target to say any of his or her kill words.",
"2. Click the word to collect points.",
"3. Review new kill words.",
"",
"The game ends when everyone has been assassinated.",
}, "<br>")
msgB, _ := json.Marshal(msg)
return ws.c.Write(ctx, 1, msgB)
}
func (ws WS) unstartedMsg(ctx context.Context, ugs *UserGameServer, gameState UserGameState) (msg map[string]any, _ error) {
msg["page"] = "A"
items := []map[string]any{}
for k := range gameState.Players {
if k == ugs.Session.ID {
continue
}
name, err := ws.games.UserName(ctx, k)
if err != nil {
return nil, err
}
items = append(items, map[string]any{"id": k, "name": name})
}
msg["items"] = items
return msg, nil
}
func (ws WS) completeMsg(ctx context.Context, gameState UserGameState) (msg map[string]any, _ error) {
msg["page"] = "B"
msg["event"] = "B"
items := []map[string]any{}
for k, v := range gameState.Players {
name, err := ws.games.UserName(ctx, k)
if err != nil {
return nil, 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
return msg, nil
}
func (ws WS) inProgressMsg(ctx context.Context, ugs *UserGameServer, gameState UserGameState) (msg map[string]any, _ error) {
msg["page"] = "B"
msg["event"] = "A"
items, err := ws.inProgressMsgItems(ctx, ugs, gameState)
if err != nil {
return nil, err
}
msg["items"] = items
return msg, nil
}
type inProgressMsgItem struct {
Name string `json:"name"`
Title string `json:"title"`
Tags []inProgressMsgItemTag `json:"tags"`
}
type inProgressMsgItemTag struct {
K string `json:"k"`
V int `json:"v,string"`
}
func (ws WS) inProgressMsgItems(ctx context.Context, ugs *UserGameServer, gameState UserGameState) ([]inProgressMsgItem, error) {
items := []inProgressMsgItem{}
for k := range gameState.Players {
item, err := ws.inProgressMsgItem(ctx, ugs, gameState, k)
if err != nil {
return nil, err
}
if item == nil {
continue
}
items = append(items, *item)
}
slices.SortFunc(items, func(a, b inProgressMsgItem) int {
an, _ := strconv.Atoi(a.Title)
bn, _ := strconv.Atoi(b.Title)
return an - bn
})
return items, nil
}
func (ws WS) inProgressMsgItem(ctx context.Context, ugs *UserGameServer, gameState UserGameState, uid string) (*inProgressMsgItem, error) {
if isSelf := uid == ugs.Session.ID; isSelf {
return nil, nil
}
self := gameState.Players[ugs.Session.ID]
v := gameState.Players[uid]
tags := []inProgressMsgItemTag{}
if canKillWithCodename := !self.KillWords.Codename.Consumed; canKillWithCodename {
tags = append(tags, newInProgressMsgItemTag(self.KillWords.Codename.KillWord))
}
for _, killWord := range append(
v.KillWords.Publics(),
v.KillWords.Privates()...,
) {
tags = append(tags, newInProgressMsgItemTag(killWord))
}
name, err := ws.games.UserName(ctx, uid)
return &inProgressMsgItem{
Name: name,
Title: strconv.Itoa(v.Points()),
Tags: tags,
}, err
}
func newInProgressMsgItemTag(kw KillWord) inProgressMsgItemTag {
return inProgressMsgItemTag{
K: kw.Word,
V: kw.Points,
}
} }

BIN
cmd/testapi/exec-testapi Executable file

Binary file not shown.

View File

@@ -6,7 +6,6 @@ import (
"fmt" "fmt"
"io" "io"
"log" "log"
"math/rand"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
@@ -72,15 +71,6 @@ func (s *S) ServeHTTP(w http.ResponseWriter, r *http.Request) {
} }
func (s *S) serveHTTP(w http.ResponseWriter, r *http.Request) error { func (s *S) serveHTTP(w http.ResponseWriter, r *http.Request) error {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Headers", "X-Auth-Token, content-type, Content-Type")
if r.Method == http.MethodOptions {
w.Header().Set("Content-Length", "0")
w.Header().Set("Content-Type", "text/plain")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS, TRACE, PATCH, HEAD, DELETE")
w.WriteHeader(http.StatusOK)
return nil
}
if isV1(r) || isWS(r) { if isV1(r) || isWS(r) {
return s.serveAPI(w, r) return s.serveAPI(w, r)
} }
@@ -122,17 +112,13 @@ type Session struct {
} }
func (s *S) injectContext(w http.ResponseWriter, r *http.Request) error { func (s *S) injectContext(w http.ResponseWriter, r *http.Request) error {
id := r.URL.Query().Get("uuid") id, err := r.Cookie("uuid")
if id == "" { if err != nil || id.Value == "" {
c, err := r.Cookie("uuid") return io.EOF
if err != nil || c.Value == "" {
return io.EOF
}
id = c.Value
} }
ctx := r.Context() ctx := r.Context()
ctx = context.WithValue(ctx, "session", Session{ ctx = context.WithValue(ctx, "session", Session{
ID: id, ID: id.Value,
}) })
*r = *r.WithContext(ctx) *r = *r.WithContext(ctx)
return nil return nil
@@ -146,9 +132,7 @@ func (s *S) Session(ctx context.Context) Session {
func (s *S) serveWS(w http.ResponseWriter, r *http.Request) error { func (s *S) serveWS(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context() ctx := r.Context()
c, err := websocket.Accept(w, r, &websocket.AcceptOptions{ c, err := websocket.Accept(w, r, nil)
InsecureSkipVerify: true,
})
if err != nil { if err != nil {
return err return err
} }
@@ -190,17 +174,9 @@ func (s *S) serveWS(w http.ResponseWriter, r *http.Request) error {
func (s *S) serveV1(w http.ResponseWriter, r *http.Request) error { func (s *S) serveV1(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context() ctx := r.Context()
switch r.Method + r.URL.Path { switch r.Method + r.URL.Path {
case "GET/v1/state/" + s.Session(ctx).ID:
if rand.Int()%2 == 0 {
w.Write([]byte(`{"name": "foo"}`))
} else {
w.Write([]byte(`{"name": "bar", "party": "party name"}`))
}
case "PUT/v1/state/" + s.Session(ctx).ID + "/party": case "PUT/v1/state/" + s.Session(ctx).ID + "/party":
w.Write([]byte(`{}`))
default:
http.NotFound(w, r)
return nil return nil
} }
http.NotFound(w, r)
return nil return nil
} }

View File

@@ -1,16 +0,0 @@
<html>
<header>
<script>
function log(msg) {
console.log(msg)
}
const ws = new Websocket("wss://out-test.breel.dev/ws")
ws.onmessage = () => { console.log("got a message") }
ws.onerror = () => { console.log("got an error") }
ws.onclose = () => { console.log("closed") }
</script>
</header>
<body>
<div id="msg"></div>
</body>
</html>

2
go.mod
View File

@@ -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.6.0 // indirect github.com/google/uuid v1.3.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
View File

@@ -8,8 +8,6 @@ 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=

View File

@@ -1,28 +0,0 @@
todo:
- global+public+private to public+private+CODENAME
- accuse event which either ends in a successful codename call worth 100% points +
disables codename OR ends in a failed codename call costing 50% codename points
- report system
- how to generate word lists??
- '"handler" system; there are both assassinations AND tenet-friendship-codeword systems
in-flight'
- dont like other players points; just order by points so winner is at top of list
- comeback/rebound system
- kingkiller system, increased bounty for those who havent died recently + high scorers
- 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
- leave game
scheduled: []
done:
- todo: refactor ws.go into like WebSocketSession struct and stuff
ts: Sun Dec 15 14:28:29 MST 2024
- todo: remake
ts: Sun Dec 15 14:28:29 MST 2024
- todo: quit
ts: Sun Dec 15 14:28:34 MST 2024
- todo: '"handler" system??'
ts: Sun Dec 15 16:43:34 MST 2024