src to pkg, impl lobby.Closed

This commit is contained in:
Bel LaPointe
2025-02-12 16:48:58 -07:00
parent 1f6b79aa3b
commit e35ddef4b7
18 changed files with 117 additions and 21 deletions

19
pkg/lib/db/ctx.go Normal file
View File

@@ -0,0 +1,19 @@
package db
import (
"context"
"database/sql"
)
func Inject(ctx context.Context, db *sql.DB) context.Context {
return context.WithValue(ctx, "__db__", db)
}
func From(ctx context.Context) *sql.DB {
return Extract(ctx)
}
func Extract(ctx context.Context) *sql.DB {
v, _ := ctx.Value("__db__").(*sql.DB)
return v
}

22
pkg/lib/db/ctx_test.go Normal file
View File

@@ -0,0 +1,22 @@
package db_test
import (
"context"
"database/sql"
"gitea/price-is-wrong/pkg/lib/db"
"testing"
)
func TestInjectDB(t *testing.T) {
ctx := context.Background()
d := &sql.DB{}
injected := db.Inject(ctx, d)
extracted := db.Extract(injected)
if d != extracted {
t.Fatal("couldnt extract injected db")
} else if extracted != db.From(injected) {
t.Fatal("couldnt from extracted db")
}
}

21
pkg/lib/db/new.go Normal file
View File

@@ -0,0 +1,21 @@
package db
import (
"context"
"database/sql"
_ "github.com/glebarez/sqlite"
)
func New(ctx context.Context, driver, conn string) (*sql.DB, error) {
db, err := sql.Open(driver, conn)
if err != nil {
return nil, err
}
if err := db.PingContext(ctx); err != nil {
return nil, err
}
return db, nil
}

View File

@@ -0,0 +1,34 @@
package event
import (
"encoding/json"
"fmt"
)
func Serialize(t string, v any) ([]byte, error) {
var wrapper struct {
Type string `json:"type"`
Payload any `json:"payload"`
}
wrapper.Type = t
wrapper.Payload = v
return json.Marshal(wrapper)
}
func Parse(b []byte, typesToPointers map[string]any) (string, error) {
var wrapper struct {
Type string `json:"type"`
Payload json.RawMessage `json:"payload"`
}
if err := json.Unmarshal(b, &wrapper); err != nil {
return "", err
}
if ptr, ok := typesToPointers[wrapper.Type]; !ok {
return "", fmt.Errorf("cannot parse unknown type %s", wrapper.Type)
} else if err := json.Unmarshal(wrapper.Payload, ptr); err != nil {
return "", fmt.Errorf("failed parsing type %s: %w", wrapper.Type, err)
}
return wrapper.Type, nil
}

23
pkg/lib/test.go Normal file
View File

@@ -0,0 +1,23 @@
package lib
import (
"context"
"path"
"testing"
"gitea/price-is-wrong/pkg/lib/db"
_ "github.com/glebarez/sqlite"
)
func NewTestCtx(t *testing.T) context.Context {
d := t.TempDir()
p := path.Join(d, "db.db")
t.Logf("test db at %s", p)
b, err := db.New(context.Background(), "sqlite", p)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { b.Close() })
return db.Inject(context.Background(), b)
}

26
pkg/lib/test_test.go Normal file
View File

@@ -0,0 +1,26 @@
package lib_test
import (
"context"
"gitea/price-is-wrong/pkg/lib"
"gitea/price-is-wrong/pkg/lib/db"
"sync"
"testing"
)
func TestTestCtx(t *testing.T) {
var ctx context.Context
wg := &sync.WaitGroup{}
wg.Add(1)
t.Run("subtest", func(t *testing.T) {
defer wg.Done()
ctx = lib.NewTestCtx(t)
if _, err := db.Extract(ctx).Exec(`SELECT 1`); err != nil {
t.Fatal(err)
}
})
wg.Wait()
if _, err := db.Extract(ctx).Exec(`SELECT 1`); err == nil {
t.Fatal(err)
}
}

3
pkg/state/generate.go Normal file
View File

@@ -0,0 +1,3 @@
package state
//go:generate cp -r ./.template ./template

58
pkg/state/lobby/' Normal file
View File

@@ -0,0 +1,58 @@
package lobby
import (
"context"
"gitea/price-is-wrong/pkg/lib/db"
lobby "gitea/price-is-wrong/pkg/state/lobby/internal"
"slices"
)
func (l *Lobby) Join(ctx context.Context, id int) error {
return l.upsertEvent(ctx, &lobby.PlayerJoin{ID: id})
}
func (l *Lobby) Leave(ctx context.Context, id int) error {
return l.upsertEvent(ctx, &lobby.PlayerLeave{ID: id})
}
func (l *Lobby) Close(ctx context.Context) error {
return l.upsertEvent(ctx, &lobby.Close{})
}
func (l *Lobby) upsertEvent(ctx context.Context, e lobby.Event) error {
if err := upsertEvent(ctx, l.id, e); err != nil {
return err
}
l2, err := openID(ctx, l.id)
if err != nil {
return err
}
*l = *l2
return nil
}
func upsertEvent(ctx context.Context, lobbyID int, e lobby.Event) error {
b, err := lobby.MarshalEvent(e)
if err != nil {
return err
}
_, err = db.From(ctx).ExecContext(ctx, `
INSERT INTO lobby_events (lobby_id, payload) VALUES (?, ?)
`, lobbyID, b)
return err
}
func (l *Lobby) apply(e lobby.Event) error {
switch e := e.(type) {
case *lobby.PlayerJoin:
if !slices.Contains(l.Players, e.ID) {
l.Players = append(l.Players, e.ID)
}
case *lobby.PlayerLeave:
l.Players = slices.DeleteFunc(l.Players, func(id int) bool { return id == e.ID })
}
return nil
}

60
pkg/state/lobby/events.go Normal file
View File

@@ -0,0 +1,60 @@
package lobby
import (
"context"
"gitea/price-is-wrong/pkg/lib/db"
lobby "gitea/price-is-wrong/pkg/state/lobby/internal"
"slices"
)
func (l *Lobby) Join(ctx context.Context, id int) error {
return l.upsertEvent(ctx, &lobby.PlayerJoin{ID: id})
}
func (l *Lobby) Leave(ctx context.Context, id int) error {
return l.upsertEvent(ctx, &lobby.PlayerLeave{ID: id})
}
func (l *Lobby) Close(ctx context.Context) error {
return l.upsertEvent(ctx, &lobby.Close{})
}
func (l *Lobby) upsertEvent(ctx context.Context, e lobby.Event) error {
if err := upsertEvent(ctx, l.id, e); err != nil {
return err
}
l2, err := openID(ctx, l.id)
if err != nil {
return err
}
*l = *l2
return nil
}
func upsertEvent(ctx context.Context, lobbyID int, e lobby.Event) error {
b, err := lobby.MarshalEvent(e)
if err != nil {
return err
}
_, err = db.From(ctx).ExecContext(ctx, `
INSERT INTO lobby_events (lobby_id, payload) VALUES (?, ?)
`, lobbyID, b)
return err
}
func (l *Lobby) apply(e lobby.Event) error {
switch e := e.(type) {
case *lobby.PlayerJoin:
if !slices.Contains(l.Players, e.ID) {
l.Players = append(l.Players, e.ID)
}
case *lobby.PlayerLeave:
l.Players = slices.DeleteFunc(l.Players, func(id int) bool { return id == e.ID })
case *lobby.Close:
l.Closed = true
}
return nil
}

View File

@@ -0,0 +1,33 @@
package lobby
import (
"fmt"
"gitea/price-is-wrong/pkg/lib/event"
)
type Event interface{}
type PlayerJoin struct {
ID int
}
type PlayerLeave struct {
ID int
}
type Close struct {
}
func ParseEvent(b []byte) (Event, error) {
typesToPointers := map[string]any{
"*lobby.PlayerJoin": &PlayerJoin{},
"*lobby.PlayerLeave": &PlayerLeave{},
"*lobby.Close": &Close{},
}
t, err := event.Parse(b, typesToPointers)
return typesToPointers[t], err
}
func MarshalEvent(e Event) ([]byte, error) {
return event.Serialize(fmt.Sprintf("%T", e), e)
}

View File

@@ -0,0 +1,37 @@
package lobby_test
import (
"fmt"
lobby "gitea/price-is-wrong/pkg/state/lobby/internal"
"testing"
)
func TestMarshalUnmarshal(t *testing.T) {
cases := map[string]struct {
e lobby.Event
}{
"PlayerJoin": {e: &lobby.PlayerJoin{ID: 1}},
"PlayerLeave": {e: &lobby.PlayerLeave{ID: 1}},
}
for name, d := range cases {
c := d
t.Run(name, func(t *testing.T) {
b, err := lobby.MarshalEvent(c.e)
if err != nil {
t.Fatal(err)
}
t.Logf("marshalled: %s", b)
e, err := lobby.ParseEvent(b)
if err != nil {
t.Fatal(err)
}
t.Logf("unmarshalled: %+v", e)
if fmt.Sprintf("%+v", c.e) != fmt.Sprintf("%+v", e) {
t.Errorf("expected %+v but got %+v", c.e, e)
}
})
}
}

View File

@@ -0,0 +1,53 @@
package lobby_test
import (
"gitea/price-is-wrong/pkg/lib"
"gitea/price-is-wrong/pkg/state/lobby"
"testing"
)
func TestOpen(t *testing.T) {
ctx := lib.NewTestCtx(t)
l, err := lobby.Open(ctx, "id")
if err != nil {
t.Fatal(err)
}
t.Logf("%+v", l)
if len(l.Players) != 0 {
t.Errorf("new lobby has players: %+v", l.Players)
}
if err := l.Join(ctx, 1); err != nil {
t.Fatal("failed to join:", err)
} else if len(l.Players) != 1 {
t.Errorf("wrong number of players after first join: %+v", l.Players)
}
if err := l.Leave(ctx, 1); err != nil {
t.Fatal("failed to join:", err)
} else if len(l.Players) != 0 {
t.Errorf("wrong number of players after only leaves: %+v", l.Players)
}
if err := l.Join(ctx, 1); err != nil {
t.Fatal("failed to join:", err)
} else if err := l.Join(ctx, 1); err != nil {
t.Fatal("failed to join redundant:", err)
} else if len(l.Players) != 1 {
t.Errorf("redundant join yielded wrong players: %+v", l.Players)
}
if err := l.Join(ctx, 2); err != nil {
t.Fatal("failed to second join:", err)
} else if len(l.Players) != 2 {
t.Errorf("second join yielded wrong players: %+v", l.Players)
}
if err := l.Close(ctx); err != nil {
t.Fatal(err)
} else if !l.Closed {
t.Error(l.Closed)
}
}

111
pkg/state/lobby/open.go Normal file
View File

@@ -0,0 +1,111 @@
package lobby
import (
"context"
"database/sql"
"fmt"
"gitea/price-is-wrong/pkg/lib/db"
lobby "gitea/price-is-wrong/pkg/state/lobby/internal"
)
type Lobby struct {
id int
Players []int
Closed bool
}
func Open(ctx context.Context, name string) (*Lobby, error) {
if err := initialize(ctx); err != nil {
return nil, fmt.Errorf("failed to initialize lobbies: %w", err)
}
if result, err := open(ctx, name); err != nil {
return nil, err
} else if result == nil {
if err := create(ctx, name); err != nil {
return nil, err
}
}
return mustOpen(ctx, name)
}
func mustOpen(ctx context.Context, name string) (*Lobby, error) {
result, err := open(ctx, name)
if err != nil {
return nil, err
}
if result == nil {
return nil, fmt.Errorf("no lobby found with name %s", name)
}
return result, nil
}
func open(ctx context.Context, name string) (*Lobby, error) {
row := db.From(ctx).QueryRow(`SELECT id FROM lobbies WHERE name=?`, name)
if err := row.Err(); err != nil {
return nil, fmt.Errorf("no lobby found with name %s", name)
}
var id sql.NullInt32
if err := row.Scan(&id); err == sql.ErrNoRows || !id.Valid {
return nil, nil
} else if err != nil {
return nil, fmt.Errorf("failed to scan id from lobby: %w", err)
}
return openID(ctx, int(id.Int32))
}
func openID(ctx context.Context, id int) (*Lobby, error) {
rows, err := db.From(ctx).QueryContext(ctx, `
SELECT payload
FROM lobby_events
WHERE lobby_events.lobby_id=?
ORDER BY id
`, id)
if err != nil {
return nil, fmt.Errorf("failed to query event payloads for id %d: %w", id, err)
}
defer rows.Close()
result := Lobby{id: id}
for rows.Next() {
var b []byte
if err := rows.Scan(&b); err != nil {
return nil, fmt.Errorf("failed to scan event: %w", err)
}
event, err := lobby.ParseEvent(b)
if err != nil {
return nil, fmt.Errorf("failed to parse event: %w", err)
}
if err := result.apply(event); err != nil {
return nil, fmt.Errorf("failed to apply event %s: %w", b, err)
}
}
return &result, rows.Err()
}
func create(ctx context.Context, name string) error {
_, err := db.From(ctx).ExecContext(ctx, `
INSERT INTO lobbies (name) VALUES (?)
`, name)
return err
}
func initialize(ctx context.Context) error {
_, err := db.From(ctx).ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS lobbies (
id INTEGER PRIMARY KEY,
name TEXT
);
CREATE TABLE IF NOT EXISTS lobby_events (
id INTEGER PRIMARY KEY,
lobby_id NUMBER,
payload TEXT,
FOREIGN KEY (lobby_id) REFERENCES lobbies (id)
);
`)
return err
}

View File

@@ -0,0 +1,100 @@
package priceiswrong
import (
"context"
"gitea/price-is-wrong/pkg/lib/db"
priceiswrong "gitea/price-is-wrong/pkg/state/priceiswrong/internal"
"html"
"math/rand"
)
func (p *PriceIsWrong) apply(e priceiswrong.Event) error {
switch e := e.(type) {
case *priceiswrong.Players:
p.Contestants = p.Contestants[:0]
for _, id := range e.IDs {
p.Contestants = append(p.Contestants, Player{ID: id})
}
if n := len(p.Contestants); n > 0 {
p.Host = p.Contestants[rand.Intn(n)].ID
}
case *priceiswrong.Host:
p.Host = e.ID
case *priceiswrong.Score:
for i := range p.Contestants {
if p.Contestants[i].ID == e.ID {
p.Contestants[i].Score += e.Score
}
}
case *priceiswrong.Item:
for i := range p.Contestants {
p.Contestants[i].Guess = ""
}
p.Item.ImageURL = e.ImageURL
p.Item.Title = html.EscapeString(e.Title)
p.Item.Description = html.EscapeString(e.Description)
p.Item.Value = html.EscapeString(e.Value)
case *priceiswrong.Guess:
for i := range p.Contestants {
if p.Contestants[i].ID == e.ID {
p.Contestants[i].Guess = html.EscapeString(e.Guess)
}
}
}
return nil
}
func (p *PriceIsWrong) SetPlayers(ctx context.Context, ids []int) error {
return p.upsertEvent(ctx, &priceiswrong.Players{IDs: ids})
}
func (p *PriceIsWrong) SetHost(ctx context.Context, id int) error {
return p.upsertEvent(ctx, &priceiswrong.Host{ID: id})
}
func (p *PriceIsWrong) Score(ctx context.Context, id, score int) error {
return p.upsertEvent(ctx, &priceiswrong.Score{ID: id, Score: score})
}
func (p *PriceIsWrong) SetItem(ctx context.Context, imageURL, title, description, value string) error {
return p.upsertEvent(ctx, &priceiswrong.Item{
ImageURL: imageURL,
Title: title,
Description: description,
Value: value,
})
}
func (p *PriceIsWrong) Guess(ctx context.Context, id int, guess string) error {
return p.upsertEvent(ctx, &priceiswrong.Guess{
ID: id,
Guess: guess,
})
}
func (p *PriceIsWrong) upsertEvent(ctx context.Context, e priceiswrong.Event) error {
if err := upsertEvent(ctx, p.id, e); err != nil {
return err
}
p2, err := openID(ctx, p.id)
if err != nil {
return err
}
*p = *p2
return nil
}
func upsertEvent(ctx context.Context, priceIsWrongID int, e priceiswrong.Event) error {
b, err := priceiswrong.MarshalEvent(e)
if err != nil {
return err
}
_, err = db.From(ctx).ExecContext(ctx, `
INSERT INTO priceiswrong_events (priceiswrong_id, payload) VALUES (?, ?)
`, priceIsWrongID, b)
return err
}

View File

@@ -0,0 +1,49 @@
package priceiswrong
import (
"fmt"
"gitea/price-is-wrong/pkg/lib/event"
)
type Event interface{}
type Players struct {
IDs []int
}
type Host struct {
ID int
}
type Score struct {
ID int
Score int
}
type Item struct {
ImageURL string
Title string
Description string
Value string
}
type Guess struct {
ID int
Guess string
}
func ParseEvent(b []byte) (Event, error) {
typesToPointers := map[string]any{
"*priceiswrong.Players": &Players{},
"*priceiswrong.Host": &Host{},
"*priceiswrong.Score": &Score{},
"*priceiswrong.Item": &Item{},
"*priceiswrong.Guess": &Guess{},
}
t, err := event.Parse(b, typesToPointers)
return typesToPointers[t], err
}
func MarshalEvent(e Event) ([]byte, error) {
return event.Serialize(fmt.Sprintf("%T", e), e)
}

View File

@@ -0,0 +1,118 @@
package priceiswrong
import (
"context"
"database/sql"
"fmt"
"gitea/price-is-wrong/pkg/lib/db"
priceiswrong "gitea/price-is-wrong/pkg/state/priceiswrong/internal"
)
type PriceIsWrong struct {
id int
Host int
Contestants []Player
Item priceiswrong.Item
}
type Player struct {
ID int
Score int
Guess string
}
func Open(ctx context.Context, name string) (*PriceIsWrong, error) {
if err := initialize(ctx); err != nil {
return nil, fmt.Errorf("failed to initialize priceiswrong: %w", err)
}
if result, err := open(ctx, name); err != nil {
return nil, err
} else if result == nil {
if err := create(ctx, name); err != nil {
return nil, err
}
}
return mustOpen(ctx, name)
}
func mustOpen(ctx context.Context, name string) (*PriceIsWrong, error) {
result, err := open(ctx, name)
if err != nil {
return nil, err
}
if result == nil {
return nil, fmt.Errorf("no priceiswrong found with name %s", name)
}
return result, nil
}
func open(ctx context.Context, name string) (*PriceIsWrong, error) {
row := db.From(ctx).QueryRow(`SELECT id FROM priceiswrongs WHERE name=?`, name)
if err := row.Err(); err != nil {
return nil, fmt.Errorf("no priceiswrong found with name %s", name)
}
var id sql.NullInt32
if err := row.Scan(&id); err == sql.ErrNoRows || !id.Valid {
return nil, nil
} else if err != nil {
return nil, fmt.Errorf("failed to scan id from priceiswrongs: %w", err)
}
return openID(ctx, int(id.Int32))
}
func openID(ctx context.Context, id int) (*PriceIsWrong, error) {
rows, err := db.From(ctx).QueryContext(ctx, `
SELECT payload
FROM priceiswrong_events
WHERE priceiswrong_events.priceiswrong_id=?
ORDER BY id
`, id)
if err != nil {
return nil, fmt.Errorf("failed to query event payloads for id %d: %w", id, err)
}
defer rows.Close()
result := PriceIsWrong{id: id}
for rows.Next() {
var b []byte
if err := rows.Scan(&b); err != nil {
return nil, fmt.Errorf("failed to scan event: %w", err)
}
event, err := priceiswrong.ParseEvent(b)
if err != nil {
return nil, fmt.Errorf("failed to parse event: %w", err)
}
if err := result.apply(event); err != nil {
return nil, fmt.Errorf("failed to apply event %s: %w", b, err)
}
}
return &result, rows.Err()
}
func create(ctx context.Context, name string) error {
_, err := db.From(ctx).ExecContext(ctx, `
INSERT INTO priceiswrongs (name) VALUES (?)
`, name)
return err
}
func initialize(ctx context.Context) error {
_, err := db.From(ctx).ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS priceiswrongs (
id INTEGER PRIMARY KEY,
name TEXT
);
CREATE TABLE IF NOT EXISTS priceiswrong_events (
id INTEGER PRIMARY KEY,
priceiswrong_id NUMBER,
payload TEXT,
FOREIGN KEY (priceiswrong_id) REFERENCES priceiswrongs (id)
);
`)
return err
}

View File

@@ -0,0 +1,62 @@
package priceiswrong_test
import (
"gitea/price-is-wrong/pkg/lib"
"gitea/price-is-wrong/pkg/state/priceiswrong"
"testing"
)
func TestPriceIsWrong(t *testing.T) {
ctx := lib.NewTestCtx(t)
p, err := priceiswrong.Open(ctx, "name")
if err != nil {
t.Fatal(err)
}
if err := p.SetPlayers(ctx, []int{4, 5, 6}); err != nil {
t.Fatal(err)
} else if len(p.Contestants) != 3 {
t.Errorf("bad contestants: %+v", p.Contestants)
} else if p.Host < 4 || p.Host > 6 {
t.Errorf("bad host: %+v", p.Host)
}
if err := p.SetHost(ctx, 5); err != nil {
t.Fatal(err)
} else if p.Host != 5 {
t.Errorf("bad host: %+v", p.Host)
}
if err := p.Score(ctx, 4, 9); err != nil {
t.Fatal(err)
} else if p.Contestants[0].Score != 9 {
t.Errorf("players changed order or didnt give 4 its points: %+v", p.Contestants)
} else if err := p.Score(ctx, 4, 9); err != nil {
t.Fatal(err)
} else if p.Contestants[0].Score != 18 {
t.Errorf("players changed order or didnt give 4 more points: %+v", p.Contestants)
} else if p.Contestants[1].Score != 0 {
t.Errorf("gave someone else points: %+v", p.Contestants)
}
if err := p.Guess(ctx, 4, "guess"); err != nil {
t.Fatal(err)
} else if p.Contestants[0].Guess != "guess" {
t.Errorf("guess didnt persist: %+v", p.Contestants)
}
if err := p.SetItem(ctx, "a", "b", "c", "d"); err != nil {
t.Fatal(err)
} else if p.Contestants[0].Guess != "" {
t.Errorf("set item didnt clear guesses")
} else if err := p.SetItem(ctx, "imageURL", "title", "description", "d"); err != nil {
t.Fatal(err)
} else if p.Item.ImageURL != "imageURL" {
t.Errorf("wrong image url: %s", p.Item.ImageURL)
} else if p.Item.Title != "title" {
t.Errorf("wrong title: %s", p.Item.Title)
} else if p.Item.Description != "description" {
t.Errorf("wrong description: %s", p.Item.Description)
}
}