Compare commits

..

5 Commits

Author SHA1 Message Date
Bel LaPointe
c18f154328 refacotr picking unused word out of AssignmentRotation body 2024-12-15 09:24:04 -07:00
Bel LaPointe
8d9815ee90 games.go tested 2024-12-15 09:11:17 -07:00
Bel LaPointe
1e25ef7a98 ws only computes game state if events since last time 2024-12-15 08:51:12 -07:00
Bel LaPointe
d1c4738796 GameState uses GameEvents 2024-12-15 08:49:43 -07:00
Bel LaPointe
1c6025e78c refactor to share 2024-12-15 08:44:51 -07:00
3 changed files with 298 additions and 120 deletions

View File

@@ -143,22 +143,27 @@ type (
EventPlayerJoin struct { EventPlayerJoin struct {
Type EventType Type EventType
Timestamp time.Time
ID string ID string
} }
EventPlayerLeave struct { EventPlayerLeave struct {
Type EventType Type EventType
Timestamp time.Time
ID string ID string
} }
EventGameComplete struct { EventGameComplete struct {
Type EventType Type EventType
Timestamp time.Time
} }
EventAssignmentRotation struct { EventAssignmentRotation struct {
Type EventType Type EventType
Timestamp time.Time
Killer string Killer string
Victim string Victim string
KillWord KillWord KillWord KillWord
KillWords map[string]KillWords AllKillWords AllKillWords
} }
AllKillWords map[string]KillWords
) )
const ( const (
@@ -168,57 +173,109 @@ const (
AssignmentRotation AssignmentRotation
) )
func (games Games) GameState(ctx context.Context, id string) (GameState, error) { type Event interface{ event() }
result := GameState{Players: map[string]PlayerState{}}
func (EventPlayerJoin) event() {}
func (EventPlayerLeave) event() {}
func (EventGameComplete) event() {}
func (EventAssignmentRotation) 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
}
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 payload []byte var b []byte
if err := rows.Scan(&timestamp, &payload); err != nil { if err := rows.Scan(&timestamp, &b); 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 { switch peek.Type {
case PlayerJoin: case PlayerJoin:
var playerJoin EventPlayerJoin var v EventPlayerJoin
if err := json.Unmarshal(payload, &playerJoin); err != nil { err := json.Unmarshal(b, &v)
return err return EventWithTime(v, timestamp), err
}
result.Players[playerJoin.ID] = PlayerState{}
return nil
case PlayerLeave: case PlayerLeave:
var playerLeave EventPlayerLeave var v EventPlayerLeave
if err := json.Unmarshal(payload, &playerLeave); err != nil { err := json.Unmarshal(b, &v)
return err return EventWithTime(v, timestamp), err
}
delete(result.Players, playerLeave.ID)
return nil
case GameComplete: case GameComplete:
var gameComplete EventGameComplete var v EventGameComplete
if err := json.Unmarshal(payload, &gameComplete); err != nil { err := json.Unmarshal(b, &v)
return err return EventWithTime(v, timestamp), err
}
result.Completed = timestamp
return nil
case AssignmentRotation: case AssignmentRotation:
result.Started = true var v EventAssignmentRotation
var assignmentRotation EventAssignmentRotation err := json.Unmarshal(b, &v)
if err := json.Unmarshal(payload, &assignmentRotation); err != nil { return EventWithTime(v, timestamp), err
return err
} }
return nil, fmt.Errorf("unknown event type %d: %s", peek.Type, b)
}
func (games Games) GameState(ctx context.Context, id string) (GameState, error) {
result := GameState{Players: map[string]PlayerState{}}
events, err := games.GameEvents(ctx, id, time.Time{})
if err != nil {
return result, err
}
for _, event := range events {
switch event.(type) {
case EventPlayerJoin:
playerJoin := event.(EventPlayerJoin)
result.Players[playerJoin.ID] = PlayerState{}
case EventPlayerLeave:
playerLeave := event.(EventPlayerLeave)
delete(result.Players, playerLeave.ID)
case EventGameComplete:
gameComplete := event.(EventGameComplete)
result.Completed = gameComplete.Timestamp
case EventAssignmentRotation:
result.Started = true
assignmentRotation := event.(EventAssignmentRotation)
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 {
} else { } else {
killer.Kills = append(killer.Kills, Kill{ killer.Kills = append(killer.Kills, Kill{
Timestamp: timestamp, Timestamp: assignmentRotation.Timestamp,
Victim: assignmentRotation.Victim, Victim: assignmentRotation.Victim,
KillWord: assignmentRotation.KillWord, KillWord: assignmentRotation.KillWord,
}) })
@@ -230,22 +287,15 @@ func (games Games) GameState(ctx context.Context, id string) (GameState, error)
result.Players[k] = v result.Players[k] = v
} }
for k, v := range assignmentRotation.KillWords { for k, v := range assignmentRotation.AllKillWords {
player := result.Players[k] player := result.Players[k]
player.KillWords = v player.KillWords = v
result.Players[k] = player result.Players[k] = player
} }
return nil
default: default:
return fmt.Errorf("unknown event type %d: %s", peek.Type, payload) return GameState{}, fmt.Errorf("unknown event type %T", event)
}
} }
return nil
}, `
SELECT timestamp, payload
FROM events
WHERE game_uuid=?
`, id)
return result, err return result, err
} }
@@ -301,9 +351,6 @@ func (games Games) CreateEventPlayerLeave(ctx context.Context, id string, player
return games.createEvent(ctx, id, EventPlayerLeave{Type: PlayerLeave, ID: player}) return games.createEvent(ctx, id, EventPlayerLeave{Type: PlayerLeave, ID: player})
} }
//go:embed holiday.txt
var wordsHoliday string
func (games Games) CreateEventAssignmentRotation(ctx context.Context, id string, killer, victim, word string, points int) error { func (games Games) CreateEventAssignmentRotation(ctx context.Context, id string, killer, victim, word string, points int) error {
state, err := games.GameState(ctx, id) state, err := games.GameState(ctx, id)
if err != nil { if err != nil {
@@ -319,7 +366,7 @@ func (games Games) CreateEventAssignmentRotation(ctx context.Context, id string,
Word: word, Word: word,
Points: points, Points: points,
}, },
KillWords: map[string]KillWords{}, AllKillWords: make(AllKillWords),
} }
toAssign := []string{} toAssign := []string{}
@@ -330,7 +377,7 @@ func (games Games) CreateEventAssignmentRotation(ctx context.Context, id string,
toAssign = append(toAssign, k) toAssign = append(toAssign, k)
doNotAssign[k] = v.Assignee doNotAssign[k] = v.Assignee
event.KillWords[k] = KillWords{ event.AllKillWords[k] = KillWords{
Global: v.Global, Global: v.Global,
Assigned: now, Assigned: now,
Assignee: "", Assignee: "",
@@ -341,13 +388,13 @@ func (games Games) CreateEventAssignmentRotation(ctx context.Context, id string,
if killerState, ok := state.Players[killer]; !ok { if killerState, ok := state.Players[killer]; !ok {
} else if victimState, ok := state.Players[victim]; !ok { } else if victimState, ok := state.Players[victim]; !ok {
} else { } else {
event.KillWords[killer] = KillWords{ event.AllKillWords[killer] = KillWords{
Global: killerState.KillWords.Global, Global: killerState.KillWords.Global,
Assigned: now, Assigned: now,
Assignee: victimState.KillWords.Assignee, Assignee: victimState.KillWords.Assignee,
Assignment: killerState.KillWords.Assignment, Assignment: killerState.KillWords.Assignment,
} }
toAssign = slices.DeleteFunc(toAssign, func(s string) bool { return s == event.KillWords[killer].Assignee }) toAssign = slices.DeleteFunc(toAssign, func(s string) bool { return s == event.AllKillWords[killer].Assignee })
if killerState.KillWords.Global.Word != word { if killerState.KillWords.Global.Word != word {
victimState.KillWords.Assignment = Assignment{} victimState.KillWords.Assignment = Assignment{}
@@ -358,17 +405,17 @@ func (games Games) CreateEventAssignmentRotation(ctx context.Context, id string,
for !func() bool { for !func() bool {
toAssign := slices.Clone(toAssign) toAssign := slices.Clone(toAssign)
doNotAssign := maps.Clone(doNotAssign) doNotAssign := maps.Clone(doNotAssign)
eventKillWords := maps.Clone(event.KillWords) allKillWords := maps.Clone(event.AllKillWords)
for i := range toAssign { for i := range toAssign {
j := rand.Intn(i + 1) j := rand.Intn(i + 1)
toAssign[i], toAssign[j] = toAssign[j], toAssign[i] toAssign[i], toAssign[j] = toAssign[j], toAssign[i]
} }
for k, v := range eventKillWords { for k, v := range allKillWords {
if k == toAssign[0] || doNotAssign[k] == toAssign[0] { if k == toAssign[0] || doNotAssign[k] == toAssign[0] {
return false return false
} }
eventKillWords[k] = KillWords{ allKillWords[k] = KillWords{
Global: v.Global, Global: v.Global,
Assigned: now, Assigned: now,
Assignee: toAssign[0], Assignee: toAssign[0],
@@ -377,66 +424,95 @@ func (games Games) CreateEventAssignmentRotation(ctx context.Context, id string,
toAssign = toAssign[1:] toAssign = toAssign[1:]
} }
event.KillWords = eventKillWords event.AllKillWords = allKillWords
return true return true
}() { }() {
} }
globalsInUse := map[string]any{} for k, v := range event.AllKillWords {
publicsInUse := map[string]any{}
privatesInUse := map[string]any{}
for _, v := range event.KillWords {
globalsInUse[v.Global.Word] = nil
for _, public := range v.Assignment.Public {
publicsInUse[public.Word] = nil
}
for _, private := range v.Assignment.Private {
privatesInUse[private.Word] = nil
}
}
randWord := func(words string, taken map[string]any) string {
wordsList := strings.Fields(words)
for {
got := wordsList[rand.Intn(len(wordsList))]
if _, ok := taken[got]; !ok {
taken[got] = nil
return got
}
}
}
randGlobal := func() string {
return randWord(wordsHoliday, globalsInUse)
}
randPublic := func() string {
return randWord(wordsHoliday, publicsInUse)
}
randPrivate := func() string {
return randWord(wordsHoliday, privatesInUse)
}
// TODO generate .Global=...us major cities?, .Assignments.Public=...?, .Assignments.Private=holiday
for k, v := range event.KillWords {
if v.Global.Word == "" { if v.Global.Word == "" {
v.Global = KillWord{Word: randGlobal(), Points: 1} v.Global = KillWord{Word: event.AllKillWords.unusedGlobal(), Points: -1}
event.AllKillWords[k] = v
} }
if len(v.Assignment.Public) == 0 { if len(v.Assignment.Public) == 0 {
v.Assignment.Public = []KillWord{ v.Assignment.Public = []KillWord{}
KillWord{Word: randPublic(), Points: 50}, for i := 0; i < 2; i++ {
KillWord{Word: randPublic(), Points: 50}, v.Assignment.Public = append(v.Assignment.Public, KillWord{Word: event.AllKillWords.unusedPublic(), Points: 50})
event.AllKillWords[k] = v
} }
} }
if len(v.Assignment.Private) == 0 { if len(v.Assignment.Private) == 0 {
v.Assignment.Private = []KillWord{ v.Assignment.Private = []KillWord{}
KillWord{Word: randPrivate(), Points: 100}, for i := 0; i < 2; i++ {
v.Assignment.Private = append(v.Assignment.Private, KillWord{Word: event.AllKillWords.unusedPrivate(), Points: 100})
event.AllKillWords[k] = v
} }
} }
event.KillWords[k] = v
} }
return games.createEvent(ctx, id, event) return games.createEvent(ctx, id, event)
} }
//go:embed holiday.txt
var wordsHoliday string
func (m AllKillWords) unusedGlobal() string {
pool := strings.Fields(wordsHoliday)
inUse := func() []string {
result := []string{}
for _, killWords := range m {
result = append(result, killWords.Global.Word)
}
return result
}
for {
picked := pool[rand.Intn(len(pool))]
if !slices.Contains(inUse(), picked) {
return picked
}
}
}
// TODO hard difficulty
func (m AllKillWords) unusedPrivate() string {
pool := strings.Fields(wordsHoliday)
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
}
}
}
// TODO medium difficulty
func (m AllKillWords) unusedPublic() string {
pool := strings.Fields(wordsHoliday)
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, ` if err := games.db.Exec(ctx, `
UPDATE games UPDATE games

View File

@@ -2,8 +2,10 @@ package main
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"testing" "testing"
"time"
) )
func newTestGames(t *testing.T) Games { func newTestGames(t *testing.T) Games {
@@ -66,15 +68,41 @@ func TestGames(t *testing.T) {
} }
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 == "" { } else if name != "player "+p {
t.Fatal("name empty") 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 { if err := games.CreateEventAssignmentRotation(ctx, id, "", "", "", 1); err != nil {
t.Fatal("err creating rotation:", err) 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 { if v, err := games.GamesForUser(ctx, "p1"); err != nil {
t.Error("err getting games for user:", err) t.Error("err getting games for user:", err)
} else if len(v) < 1 { } else if len(v) < 1 {
@@ -96,6 +124,9 @@ func TestGames(t *testing.T) {
} else { } else {
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 v.Players[p].Points() != 0 {
t.Error("nonzero points after zero kills:", v.Players[p].Points())
}
if v.Players[p].KillWords.Global.Word == "" { if v.Players[p].KillWords.Global.Word == "" {
t.Error(p, "no killwords.global") t.Error(p, "no killwords.global")
} else if v.Players[p].KillWords.Assigned.IsZero() { } else if v.Players[p].KillWords.Assigned.IsZero() {
@@ -119,3 +150,66 @@ func TestGames(t *testing.T) {
} }
}) })
} }
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{
Global: KillWord{
Word: "a",
Points: -1,
},
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)
}
})
}
}

View File

@@ -90,6 +90,7 @@ func (s *S) serveWS(w http.ResponseWriter, r *http.Request) error {
} }
}() }()
var last time.Time
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
@@ -97,6 +98,13 @@ func (s *S) serveWS(w http.ResponseWriter, r *http.Request) error {
case <-time.After(time.Second * 1): 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) gameState, err := s.games.GameState(ctx, game)
if err != nil { if err != nil {
return err return err