Compare commits

..

53 Commits

Author SHA1 Message Date
Bel LaPointe
617785ad51 f u wannabe cors 2024-12-18 19:45:45 -07:00
Bel LaPointe
d2fa707628 tests pass but LOTS of todos 2024-12-16 17:29:27 -07:00
bel
421331eb71 i have a plan but also realize event state computation CANNOT create events because itll evalulate many times 2024-12-15 22:28:26 -07:00
Bel LaPointe
c6ffa12354 grr how do trial 2024-12-15 19:03:35 -07:00
Bel LaPointe
58fae19522 stub codename trial, accuse events 2024-12-15 18:43:15 -07:00
Bel LaPointe
4abac89472 use codename.Consumed instead of kills to know whether codename is available for murdering 2024-12-15 18:36:19 -07:00
Bel LaPointe
15078a626d Global-1 to Codename+200 2024-12-15 18:25:47 -07:00
Bel LaPointe
02dc21c124 todo 2024-12-15 17:53:38 -07:00
Bel LaPointe
4d9abef04c todo 2024-12-15 16:40:59 -07:00
Bel LaPointe
557e1ec6d4 accept ?uuid=X instead of cookie 2024-12-15 14:39:14 -07:00
Bel LaPointe
3095d54f99 testapi accepts ?uuid instead of cookie.uuid 2024-12-15 14:34:59 -07:00
Bel LaPointe
d6db4a8a58 oops cross platform testing doesnt play nice with cookies 2024-12-15 14:33:18 -07:00
Bel LaPointe
d36ef7b629 todo.yaml 2024-12-15 14:29:41 -07:00
Bel LaPointe
ab3a549f78 cors pls 2024-12-15 14:24:20 -07:00
Bel LaPointe
bf3b341b69 GameReset event 2024-12-15 14:17:07 -07:00
Bel LaPointe
54116131b3 stub GameReset event 2024-12-15 14:09:54 -07:00
Bel LaPointe
57dd66e510 impl first game reset but realize it must be an event to broadcast to all players 2024-12-15 14:06:08 -07:00
Bel LaPointe
f1282f588d impl ws kill 2024-12-15 13:56:59 -07:00
Bel LaPointe
706d55631b games.UserByName 2024-12-15 13:50:27 -07:00
Bel LaPointe
0b22ba4bd2 random name generation done 2024-12-15 13:41:57 -07:00
Bel LaPointe
f7a303168a GameByName doesnt take uid 2024-12-15 13:26:28 -07:00
Bel LaPointe
37291e68aa ugs listen needs love 2024-12-15 13:17:36 -07:00
Bel LaPointe
5109dc9fdb test usergameserver.State after game completes WOO 2024-12-15 13:16:04 -07:00
Bel LaPointe
fc899056e0 test game state after some kills 2024-12-15 13:13:03 -07:00
Bel LaPointe
8d73f97c3a bugfix if victim was targeting killer then killer cant target itself 2024-12-15 13:03:37 -07:00
Bel LaPointe
598cb0684c test UserGameState of freshly started game 2024-12-15 12:52:10 -07:00
Bel LaPointe
aba5225ed2 unittest not impls 2024-12-15 12:42:53 -07:00
Bel LaPointe
f3f70e10f4 test UserGameServer unstarted state has all empty players 2024-12-15 12:39:36 -07:00
Bel LaPointe
1e4198b291 drop temp fail 2024-12-15 12:27:17 -07:00
Bel LaPointe
c760dac44b ws blindly pushes public, private and lets UserGameServer limit visibility of user 2024-12-15 12:26:30 -07:00
Bel LaPointe
c1933dc180 sort ws tags 2024-12-15 12:03:21 -07:00
Bel LaPointe
9a74575e6c refactor out ws.inProgressMsgItem 2024-12-15 11:45:42 -07:00
Bel LaPointe
8d0ded9ee9 refactors 2024-12-15 11:35:00 -07:00
Bel LaPointe
e8817f9e74 refactor out incomplete msg 2024-12-15 11:32:23 -07:00
Bel LaPointe
3a83fe7c17 refactor out ws.unstartedMsg 2024-12-15 11:28:42 -07:00
Bel LaPointe
c6da3d17a1 refactor pushing state over websocket into a func 2024-12-15 11:24:51 -07:00
Bel LaPointe
64165c5745 refactor pushing state over websocket into a func 2024-12-15 11:24:32 -07:00
Bel LaPointe
74a403fa6d drop logs 2024-12-15 11:10:37 -07:00
Bel LaPointe
e95b63d9ce renmae 2024-12-15 11:09:44 -07:00
Bel LaPointe
2c3e870750 ugs aliases game state, shorter help sentences 2024-12-15 11:09:25 -07:00
Bel LaPointe
c3e9c18e95 refactor 2024-12-15 11:06:13 -07:00
Bel LaPointe
39c9eae7ad ugs.more blocks until event arrives 2024-12-15 11:05:39 -07:00
Bel LaPointe
0d44fd56ed extract reading from websocket into UserGameServer 2024-12-15 11:01:48 -07:00
Bel LaPointe
51006c7946 total coverage of db and games naisu 2024-12-15 10:49:33 -07:00
Bel LaPointe
94d3d97645 test fill 2024-12-15 10:23:13 -07:00
Bel LaPointe
eb3a30ec8a test hangs due to fill inf looping 2024-12-15 10:19:04 -07:00
Bel LaPointe
6340469d53 killing them 200 line functions one at a time 2024-12-15 09:55:08 -07:00
Bel LaPointe
d05ab6d0ec killing does not change assignment 2024-12-15 09:51:40 -07:00
Bel LaPointe
ae593bc092 refactor making new AllKillWords just without assignees and now 2024-12-15 09:50:34 -07:00
Bel LaPointe
ff5071215a functional looking 2024-12-15 09:49:09 -07:00
Bel LaPointe
b9d06f81ba functional looking 2024-12-15 09:47:11 -07:00
Bel LaPointe
51c7cc4496 sh 2024-12-15 09:46:21 -07:00
Bel LaPointe
d7e4196f61 refactorinnng 2024-12-15 09:44:13 -07:00
12 changed files with 1747 additions and 309 deletions

249
cmd/server/adjectives.txt Normal file
View File

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

100
cmd/server/animals.txt Normal file
View File

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

@@ -11,6 +11,7 @@ import (
"slices" "slices"
"strings" "strings"
"time" "time"
"unicode"
"github.com/google/uuid" "github.com/google/uuid"
) )
@@ -62,9 +63,24 @@ func (games Games) GamesForUser(ctx context.Context, id string) ([]string, error
} }
func (games Games) UpdateUserName(ctx context.Context, id, name string) error { func (games Games) UpdateUserName(ctx context.Context, id, name string) error {
return games.db.Exec(ctx, `UPDATE users SET name=? WHERE uuid=?`, name, id) 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) { func (games Games) UserName(ctx context.Context, id string) (string, error) {
result := "" result := ""
err := games.db.Query(ctx, func(rows *sql.Rows) error { err := games.db.Query(ctx, func(rows *sql.Rows) error {
@@ -74,9 +90,44 @@ func (games Games) UserName(ctx context.Context, id string) (string, error) {
FROM users FROM users
WHERE users.uuid=? WHERE users.uuid=?
`, id) `, 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 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 { func (s PlayerState) Points() int {
points := 0 points := 0
for _, kill := range s.Kills { for _, kill := range s.Kills {
@@ -85,28 +136,47 @@ func (s PlayerState) Points() int {
return points return points
} }
func (games Games) GameByName(ctx context.Context, uid, name string) (string, error) { func (games Games) GameName(ctx context.Context, id string) (string, error) {
var result string var result string
err := games.db.Query(ctx, func(rows *sql.Rows) error { err := games.db.Query(ctx, func(rows *sql.Rows) error {
return rows.Scan(&result) return rows.Scan(&result)
}, ` }, `
SELECT SELECT
players.game_uuid games.name
FROM FROM
players games
JOIN games ON players.game_uuid=games.uuid WHERE games.uuid=?
WHERE players.user_uuid=? AND games.name=?
ORDER BY games.updated DESC ORDER BY games.updated DESC
LIMIT 1 LIMIT 1
`, uid, name) `, id)
return result, err
}
func (games Games) GameByName(ctx context.Context, name string) (string, error) {
var result string
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 return result, err
} }
type ( type (
GameState struct { GameState struct {
ID string
Started bool Started bool
Completed time.Time Completed time.Time
Players map[string]PlayerState
Players map[string]PlayerState
Trial Trial
} }
PlayerState struct { PlayerState struct {
@@ -121,7 +191,7 @@ type (
} }
KillWords struct { KillWords struct {
Global KillWord Codename Codename
Assigned time.Time Assigned time.Time
Assignee string Assignee string
@@ -129,6 +199,11 @@ type (
Assignment Assignment Assignment Assignment
} }
Codename struct {
KillWord KillWord
Consumed bool
}
Assignment struct { Assignment struct {
Public []KillWord Public []KillWord
Private []KillWord Private []KillWord
@@ -139,6 +214,12 @@ type (
Points int Points int
} }
Trial struct {
Prosecutor string
Defendant string
Word string
}
EventType int EventType int
EventPlayerJoin struct { EventPlayerJoin struct {
@@ -163,6 +244,29 @@ type (
KillWord KillWord KillWord KillWord
AllKillWords AllKillWords 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 AllKillWords map[string]KillWords
) )
@@ -171,6 +275,10 @@ const (
PlayerLeave PlayerLeave
GameComplete GameComplete
AssignmentRotation AssignmentRotation
GameReset
CodenameAccusal
CodenameTrial
Notification
) )
type Event interface{ event() } type Event interface{ event() }
@@ -179,6 +287,10 @@ func (EventPlayerJoin) event() {}
func (EventPlayerLeave) event() {} func (EventPlayerLeave) event() {}
func (EventGameComplete) event() {} func (EventGameComplete) event() {}
func (EventAssignmentRotation) event() {} func (EventAssignmentRotation) event() {}
func (EventGameReset) event() {}
func (EventCodenameAccusal) event() {}
func (EventCodenameTrial) event() {}
func (EventNotification) event() {}
func EventWithTime(event Event, t time.Time) Event { func EventWithTime(event Event, t time.Time) Event {
switch e := event.(type) { switch e := event.(type) {
@@ -194,6 +306,18 @@ func EventWithTime(event Event, t time.Time) Event {
case EventAssignmentRotation: case EventAssignmentRotation:
e.Timestamp = t e.Timestamp = t
event = e 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 return event
} }
@@ -244,12 +368,28 @@ func parseEvent(b []byte, timestamp time.Time) (Event, error) {
var v EventAssignmentRotation var v EventAssignmentRotation
err := json.Unmarshal(b, &v) err := json.Unmarshal(b, &v)
return EventWithTime(v, timestamp), err 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) return nil, fmt.Errorf("unknown event type %d: %s", peek.Type, b)
} }
func (games Games) GameState(ctx context.Context, id string) (GameState, error) { func (games Games) GameState(ctx context.Context, id string) (GameState, error) {
result := GameState{Players: map[string]PlayerState{}} result := GameState{ID: id, Players: map[string]PlayerState{}}
events, err := games.GameEvents(ctx, id, time.Time{}) events, err := games.GameEvents(ctx, id, time.Time{})
if err != nil { if err != nil {
@@ -257,19 +397,16 @@ func (games Games) GameState(ctx context.Context, id string) (GameState, error)
} }
for _, event := range events { for _, event := range events {
switch event.(type) { switch e := event.(type) {
case EventPlayerJoin: case EventPlayerJoin:
playerJoin := event.(EventPlayerJoin) result.Players[e.ID] = PlayerState{}
result.Players[playerJoin.ID] = PlayerState{}
case EventPlayerLeave: case EventPlayerLeave:
playerLeave := event.(EventPlayerLeave) delete(result.Players, e.ID)
delete(result.Players, playerLeave.ID)
case EventGameComplete: case EventGameComplete:
gameComplete := event.(EventGameComplete) result.Completed = e.Timestamp
result.Completed = gameComplete.Timestamp
case EventAssignmentRotation: case EventAssignmentRotation:
result.Started = true result.Started = true
assignmentRotation := event.(EventAssignmentRotation) assignmentRotation := e
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 _, ok := result.Players[assignmentRotation.Victim]; !ok {
@@ -292,6 +429,48 @@ func (games Games) GameState(ctx context.Context, id string) (GameState, error)
player.KillWords = v player.KillWords = v
result.Players[k] = player result.Players[k] = player
} }
case EventCodenameAccusal:
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 GameState{}, fmt.Errorf("unknown event type %T", event)
} }
@@ -300,6 +479,20 @@ func (games Games) GameState(ctx context.Context, id string) (GameState, error)
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) { func (games Games) CreateGame(ctx context.Context, name string) (string, error) {
var exists string var exists string
if err := games.db.Query(ctx, if err := games.db.Query(ctx,
@@ -326,14 +519,8 @@ func (games Games) CreateGame(ctx context.Context, name string) (string, error)
`, id, time.Now(), name) `, id, time.Now(), name)
} }
func (games Games) CreateEventPlayerJoin(ctx context.Context, id string, player, name string) error { func (games Games) CreateEventPlayerJoin(ctx context.Context, id string, player string) error {
if err := games.db.Exec(ctx, ` if _, err := games.UserName(ctx, player); err != nil {
INSERT INTO users (
uuid,
name
) VALUES (?, ?)
ON CONFLICT DO UPDATE SET name=? WHERE uuid=?;
`, player, name, name, player); err != nil {
return err return err
} }
if err := games.db.Exec(ctx, ` if err := games.db.Exec(ctx, `
@@ -356,7 +543,6 @@ func (games Games) CreateEventAssignmentRotation(ctx context.Context, id string,
if err != nil { if err != nil {
return err return err
} }
now := time.Now()
event := EventAssignmentRotation{ event := EventAssignmentRotation{
Type: AssignmentRotation, Type: AssignmentRotation,
@@ -366,102 +552,207 @@ func (games Games) CreateEventAssignmentRotation(ctx context.Context, id string,
Word: word, Word: word,
Points: points, Points: points,
}, },
AllKillWords: make(AllKillWords),
} }
toAssign := []string{} prevAllKillWords := state.AllKillWords()
doNotAssign := map[string]string{}
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 { for k, v := range state.Players {
v := v.KillWords m[k] = v.KillWords
toAssign = append(toAssign, k) }
doNotAssign[k] = v.Assignee return m
}
event.AllKillWords[k] = KillWords{ func (games Games) CreateEventGameReset(ctx context.Context, gid string) error {
Global: v.Global, state, err := games.GameState(ctx, gid)
Assigned: now, if err != nil {
Assignee: "", return err
Assignment: v.Assignment, }
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)
}
if killerState, ok := state.Players[killer]; !ok { func (words KillWords) Empty() bool {
} else if victimState, ok := state.Players[victim]; !ok { return words.Codename == (Codename{}) && words.Assigned.IsZero() && words.Assignee == "" && words.Assignment.Empty()
} else { }
event.AllKillWords[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.AllKillWords[killer].Assignee })
if killerState.KillWords.Global.Word != word { func (words KillWords) Privates() []KillWord {
victimState.KillWords.Assignment = Assignment{} a := slices.Clone(words.Assignment.Private)
state.Players[victim] = victimState 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 { for !func() bool {
toAssign := slices.Clone(toAssign) allKillWords := maps.Clone(m)
doNotAssign := maps.Clone(doNotAssign) unassigned := allKillWords.unassigned()
allKillWords := maps.Clone(event.AllKillWords) pop := func() string {
result := unassigned[0]
for i := range toAssign { unassigned = unassigned[1:]
j := rand.Intn(i + 1) return result
toAssign[i], toAssign[j] = toAssign[j], toAssign[i]
} }
for k, v := range allKillWords { for k, v := range allKillWords {
if k == toAssign[0] || doNotAssign[k] == toAssign[0] { if v.Assignee != "" {
continue
}
v.Assignee = pop()
if k == v.Assignee || prev[k].Assignee == v.Assignee {
return false return false
} }
allKillWords[k] = KillWords{ allKillWords[k] = v
Global: v.Global,
Assigned: now,
Assignee: toAssign[0],
Assignment: v.Assignment,
}
toAssign = toAssign[1:]
} }
event.AllKillWords = allKillWords if len(unassigned) > 0 {
panic(unassigned)
}
m = allKillWords
return true return true
}() { }() {
} }
for k, v := range event.AllKillWords { return m
if v.Global.Word == "" { }
v.Global = KillWord{Word: event.AllKillWords.unusedGlobal(), Points: -1}
event.AllKillWords[k] = v func (m AllKillWords) assign(killer, victim string) {
} v := m[killer]
if len(v.Assignment.Public) == 0 { v.Assignee = victim
v.Assignment.Public = []KillWord{} m[killer] = v
for i := 0; i < 2; i++ { }
v.Assignment.Public = append(v.Assignment.Public, KillWord{Word: event.AllKillWords.unusedPublic(), Points: 50})
event.AllKillWords[k] = v func (m AllKillWords) withoutAssignees() AllKillWords {
} now := time.Now()
} result := make(AllKillWords)
if len(v.Assignment.Private) == 0 { for k := range m {
v.Assignment.Private = []KillWord{} result[k] = KillWords{
for i := 0; i < 2; i++ { Codename: m[k].Codename,
v.Assignment.Private = append(v.Assignment.Private, KillWord{Word: event.AllKillWords.unusedPrivate(), Points: 100}) Assigned: now,
event.AllKillWords[k] = v Assignee: "",
} Assignment: m[k].Assignment,
} }
} }
return result
}
return games.createEvent(ctx, id, event) 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 //go:embed holiday.txt
var wordsHoliday string var wordsHoliday string
func (m AllKillWords) unusedGlobal() string { func (m AllKillWords) FillKillWords() AllKillWords {
pool := strings.Fields(wordsHoliday) 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 { inUse := func() []string {
result := []string{} result := []string{}
for _, killWords := range m { for _, killWords := range m {
result = append(result, killWords.Global.Word) result = append(result, killWords.Codename.KillWord.Word)
} }
return result return result
} }
@@ -473,9 +764,7 @@ func (m AllKillWords) unusedGlobal() string {
} }
} }
// TODO hard difficulty func (m AllKillWords) unusedPrivate(pool []string) string {
func (m AllKillWords) unusedPrivate() string {
pool := strings.Fields(wordsHoliday)
inUse := func() []string { inUse := func() []string {
result := []string{} result := []string{}
for _, killWords := range m { for _, killWords := range m {
@@ -493,9 +782,7 @@ func (m AllKillWords) unusedPrivate() string {
} }
} }
// TODO medium difficulty func (m AllKillWords) unusedPublic(pool []string) string {
func (m AllKillWords) unusedPublic() string {
pool := strings.Fields(wordsHoliday)
inUse := func() []string { inUse := func() []string {
result := []string{} result := []string{}
for _, killWords := range m { for _, killWords := range m {
@@ -524,6 +811,23 @@ func (games Games) CreateEventGameComplete(ctx context.Context, id string) error
return games.createEvent(ctx, id, EventGameComplete{Type: GameComplete}) return games.createEvent(ctx, id, EventGameComplete{Type: GameComplete})
} }
func (games Games) CreateEventCodenameAccusal(ctx context.Context, gid, prosecutor, defendant, codename string) error {
return fmt.Errorf("not impl: x accused y")
return fmt.Errorf("not impl: x caught by y")
}
func (games Games) CreateEventCodenameTrial(ctx context.Context, gid string, guilty bool) error {
return fmt.Errorf("not impl: x found guilty/notguilty")
}
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(v)
if err != nil { if err != nil {

View File

@@ -1,6 +1,7 @@
package main package main
import ( import (
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
@@ -30,7 +31,7 @@ func TestGames(t *testing.T) {
t.Error(v) t.Error(v)
} }
if v, err := games.GameByName(ctx, "", ""); err != nil { if v, err := games.GameByName(ctx, ""); err != nil {
t.Error("err getting game by empty name for empty user:", err) t.Error("err getting game by empty name for empty user:", err)
} else if len(v) > 0 { } else if len(v) > 0 {
t.Error(v) t.Error(v)
@@ -55,7 +56,7 @@ func TestGames(t *testing.T) {
t.Fatal("redundant create game didnt return same id:", id2) t.Fatal("redundant create game didnt return same id:", id2)
} }
if err := games.CreateEventPlayerJoin(ctx, id, "p0", "player zero"); err != nil { if err := games.CreateEventPlayerJoin(ctx, id, "p0"); err != nil {
t.Fatal("err creating event player join:", err) t.Fatal("err creating event player join:", err)
} else if err := games.CreateEventPlayerLeave(ctx, id, "p0"); err != nil { } else if err := games.CreateEventPlayerLeave(ctx, id, "p0"); err != nil {
t.Fatal("err creating event player leave:", err) t.Fatal("err creating event player leave:", err)
@@ -63,12 +64,12 @@ func TestGames(t *testing.T) {
for i := 0; i < 4; i++ { for i := 0; i < 4; i++ {
p := fmt.Sprintf("p%d", i+1) p := fmt.Sprintf("p%d", i+1)
if err := games.CreateEventPlayerJoin(ctx, id, p, "player "+p); err != nil { if err := games.CreateEventPlayerJoin(ctx, id, p); err != nil {
t.Fatal(p, "err creating event player join", err) t.Fatal(p, "err creating event player join", err)
} }
if name, err := games.UserName(ctx, p); err != nil { if name, err := games.UserName(ctx, p); err != nil {
t.Fatal(p, "err getting user name", err) t.Fatal(p, "err getting user name", err)
} else if name != "player "+p { } else if name == "" {
t.Fatal("name wrong", name) t.Fatal("name wrong", name)
} }
if err := games.UpdateUserName(ctx, p, "player! "+p); err != nil { if err := games.UpdateUserName(ctx, p, "player! "+p); err != nil {
@@ -111,7 +112,7 @@ func TestGames(t *testing.T) {
t.Error("wrong game found for user:", v) t.Error("wrong game found for user:", v)
} }
if v, err := games.GameByName(ctx, "p1", "g1"); err != nil { if v, err := games.GameByName(ctx, "g1"); err != nil {
t.Error("err getting game by name for user:", err) t.Error("err getting game by name for user:", err)
} else if v != id { } else if v != id {
t.Error("wrong game by name for user:", v) t.Error("wrong game by name for user:", v)
@@ -127,8 +128,8 @@ func TestGames(t *testing.T) {
if v.Players[p].Points() != 0 { if v.Players[p].Points() != 0 {
t.Error("nonzero points after zero kills:", v.Players[p].Points()) t.Error("nonzero points after zero kills:", v.Players[p].Points())
} }
if v.Players[p].KillWords.Global.Word == "" { if v.Players[p].KillWords.Codename.KillWord.Word == "" {
t.Error(p, "no killwords.global") t.Error(p, "no killwords.Codename")
} else if v.Players[p].KillWords.Assigned.IsZero() { } else if v.Players[p].KillWords.Assigned.IsZero() {
t.Error(p, "no killwords.assigned") t.Error(p, "no killwords.assigned")
} else if v.Players[p].KillWords.Assignee == "" { } else if v.Players[p].KillWords.Assignee == "" {
@@ -148,6 +149,28 @@ func TestGames(t *testing.T) {
} else if state.Completed.IsZero() { } else if state.Completed.IsZero() {
t.Fatal("state.Completed is zero") 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)
}
}) })
} }
@@ -176,10 +199,10 @@ func TestParseEvent(t *testing.T) {
}, },
AllKillWords: map[string]KillWords{ AllKillWords: map[string]KillWords{
"x": KillWords{ "x": KillWords{
Global: KillWord{ Codename: Codename{KillWord: KillWord{
Word: "a", Word: "a",
Points: -1, Points: 200,
}, }},
Assignee: "z", Assignee: "z",
Assigned: now, Assigned: now,
Assignment: Assignment{ Assignment: Assignment{
@@ -213,3 +236,203 @@ func TestParseEvent(t *testing.T) {
}) })
} }
} }
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

@@ -59,13 +59,19 @@ 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, err := r.Cookie("uuid") id := r.Header.Get("uuid")
if err != nil || id.Value == "" { if id == "" {
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.Value, ID: id,
}) })
*r = *r.WithContext(ctx) *r = *r.WithContext(ctx)
return nil return nil

View File

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

@@ -0,0 +1,291 @@
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,9 +1,12 @@
package main package main
import ( import (
"fmt" "bytes"
"encoding/json"
"io"
"net/http" "net/http"
"path" "path"
"slices"
"strings" "strings"
) )
@@ -12,8 +15,29 @@ 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 "PUT/v1/state/" + s.Session(r.Context()).ID + "/party": case "GET/v1/state/" + uid:
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)
@@ -22,5 +46,27 @@ 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 {
return fmt.Errorf("not impl") party, err := io.ReadAll(r.Body)
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

@@ -4,13 +4,10 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"log"
"net/http" "net/http"
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/coder/websocket" "github.com/coder/websocket"
) )
@@ -19,198 +16,203 @@ 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 {
*S
c *websocket.Conn
}
func (s *S) serveWS(w http.ResponseWriter, r *http.Request) error { func (s *S) serveWS(w http.ResponseWriter, r *http.Request) error {
ctx, can := context.WithCancel(r.Context())
defer can()
r = r.WithContext(ctx)
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) 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)
}
go func() { func (ws WS) Serve(w http.ResponseWriter, r *http.Request) error {
defer can() ctx, can := context.WithCancel(r.Context())
for { defer can()
_, b, err := c.Read(ctx) r = r.WithContext(ctx)
if err != nil {
log.Println(err)
return
}
log.Printf("READ %s", b)
var m map[string]string ugs, err := ws.newUserGameServer(ctx)
if err := json.Unmarshal(b, &m); err != nil { if err != nil {
log.Println(err) return err
return }
}
log.Printf("UNMARSHAL %+v", m)
if m["party"] == "start" { go ugs.Listen(ctx, can, func(ctx context.Context) ([]byte, error) {
if gameState, err := s.games.GameState(ctx, game); err != nil { _, b, err := ws.c.Read(ctx)
log.Println(err) return b, 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
}
}
}()
var last time.Time for ugs.More(ctx) == nil {
for { if err := ws.Push(ctx, ugs); err != nil {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(time.Second * 1):
}
if events, err := s.games.GameEvents(ctx, game, last); err != nil {
return err
} else if len(events) == 0 {
continue
}
last = time.Now()
gameState, err := s.games.GameState(ctx, game)
if err != nil {
return err
}
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 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,
}
} }

View File

@@ -2,11 +2,11 @@ package main
import ( import (
"context" "context"
"crypto/rand"
"flag" "flag"
"fmt" "fmt"
"io" "io"
"log" "log"
"math/rand"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
@@ -72,6 +72,15 @@ 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)
} }
@@ -113,13 +122,17 @@ 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, err := r.Cookie("uuid") id := r.URL.Query().Get("uuid")
if err != nil || id.Value == "" { if id == "" {
return io.EOF c, err := r.Cookie("uuid")
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.Value, ID: id,
}) })
*r = *r.WithContext(ctx) *r = *r.WithContext(ctx)
return nil return nil
@@ -133,7 +146,9 @@ 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, nil) c, err := websocket.Accept(w, r, &websocket.AcceptOptions{
InsecureSkipVerify: true,
})
if err != nil { if err != nil {
return err return err
} }
@@ -177,12 +192,12 @@ func (s *S) serveV1(w http.ResponseWriter, r *http.Request) error {
switch r.Method + r.URL.Path { switch r.Method + r.URL.Path {
case "GET/v1/state/" + s.Session(ctx).ID: case "GET/v1/state/" + s.Session(ctx).ID:
if rand.Int()%2 == 0 { if rand.Int()%2 == 0 {
w.Write(`{"name": "foo"}`) w.Write([]byte(`{"name": "foo"}`))
} else { } else {
w.Write(`{"name": "bar", "party": "party name"}`) 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(`{}`) w.Write([]byte(`{}`))
default: default:
http.NotFound(w, r) http.NotFound(w, r)
return nil return nil

16
cmd/testws/index.html Normal file
View File

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

View File

@@ -1,12 +1,28 @@
todo: todo:
- refactor ws.go into like WebSocketSession struct and stuff - 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 - test ws flow
- notifications system with dismissal server-side so users see X got a kill - notifications system with dismissal server-side so users see X got a kill
- play mp3 on kill + shuffle - play mp3 on kill + shuffle
- end condition; everyone has died - end condition; everyone has died
- word lists; already got holidays - word lists; already got holidays
- event driven woulda been nice - event driven woulda been nice
- remake - leave game
- quit
scheduled: [] scheduled: []
done: [] 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