diff --git a/.gitignore b/.gitignore index 656e2fb..cf42baf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ c4 c4.db +data/ diff --git a/game/store.go b/game/store.go index 3b6735f..cd5ddc6 100644 --- a/game/store.go +++ b/game/store.go @@ -4,16 +4,14 @@ import ( "crypto/rand" "encoding/hex" "sync" - "time" ) -type Syncable interface { - Sync() +type PubSub interface { + Publish(subject string, data []byte) error } type PlayerSession struct { Player *Player - Sync Syncable } type Persister interface { @@ -28,6 +26,7 @@ type GameStore struct { games map[string]*GameInstance gamesMu sync.RWMutex persister Persister + pubsub PubSub } func NewGameStore() *GameStore { @@ -40,10 +39,23 @@ func (gs *GameStore) SetPersister(p Persister) { gs.persister = p } +func (gs *GameStore) SetPubSub(ps PubSub) { + gs.pubsub = ps +} + +func (gs *GameStore) makeNotify(gameID string) func() { + return func() { + if gs.pubsub != nil { + gs.pubsub.Publish("game."+gameID, nil) + } + } +} + func (gs *GameStore) Create() *GameInstance { id := GenerateID(4) gi := NewGameInstance(id) gi.persister = gs.persister + gi.notify = gs.makeNotify(id) gs.gamesMu.Lock() gs.games[id] = gi gs.gamesMu.Unlock() @@ -52,7 +64,6 @@ func (gs *GameStore) Create() *GameInstance { gs.persister.SaveGame(gi.game) } - go gi.run() return gi } @@ -65,7 +76,6 @@ func (gs *GameStore) Get(id string) (*GameInstance, bool) { return gi, true } - // Try to load from database if gs.persister == nil { return nil, false } @@ -86,27 +96,20 @@ func (gs *GameStore) Get(id string) (*GameInstance, bool) { gi = &GameInstance{ game: game, - players: make(map[PlayerID]Syncable), - leave: make(chan PlayerID, 5), - done: make(chan struct{}), persister: gs.persister, + notify: gs.makeNotify(id), } gs.gamesMu.Lock() gs.games[id] = gi gs.gamesMu.Unlock() - go gi.run() return gi, true } func (gs *GameStore) Delete(id string) error { gs.gamesMu.Lock() - gi, ok := gs.games[id] - if ok { - delete(gs.games, id) - close(gi.done) - } + delete(gs.games, id) gs.gamesMu.Unlock() if gs.persister != nil { @@ -124,20 +127,14 @@ func GenerateID(size int) string { type GameInstance struct { game *Game gameMu sync.RWMutex - players map[PlayerID]Syncable - playersMu sync.RWMutex - leave chan PlayerID - done chan struct{} - dirty bool + notify func() persister Persister } func NewGameInstance(id string) *GameInstance { return &GameInstance{ - game: NewGame(id), - players: make(map[PlayerID]Syncable), - leave: make(chan PlayerID, 5), - done: make(chan struct{}), + game: NewGame(id), + notify: func() {}, } } @@ -152,30 +149,25 @@ func (gi *GameInstance) Join(ps *PlayerSession) bool { defer gi.gameMu.Unlock() var slot int - // Assign player to an open slot if gi.game.Players[0] == nil { - ps.Player.Color = 1 // Red + ps.Player.Color = 1 gi.game.Players[0] = ps.Player slot = 0 } else if gi.game.Players[1] == nil { - ps.Player.Color = 2 // Yellow + ps.Player.Color = 2 gi.game.Players[1] = ps.Player gi.game.Status = StatusInProgress slot = 1 } else { - return false // Game is full + return false } - gi.playersMu.Lock() - gi.players[ps.Player.ID] = ps.Sync - gi.playersMu.Unlock() - if gi.persister != nil { gi.persister.SaveGamePlayer(gi.game.ID, ps.Player, slot) gi.persister.SaveGame(gi.game) } - gi.dirty = true + gi.notify() return true } @@ -196,13 +188,6 @@ func (gi *GameInstance) GetPlayerColor(pid PlayerID) int { return 0 } -func (gi *GameInstance) RegisterSync(playerID PlayerID, sync Syncable) { - gi.playersMu.Lock() - gi.players[playerID] = sync - gi.playersMu.Unlock() - gi.dirty = true -} - func (gi *GameInstance) CreateRematch(gs *GameStore) *GameInstance { gi.gameMu.Lock() defer gi.gameMu.Unlock() @@ -223,7 +208,7 @@ func (gi *GameInstance) CreateRematch(gs *GameStore) *GameInstance { } } - gi.dirty = true + gi.notify() return newGI } @@ -253,45 +238,6 @@ func (gi *GameInstance) DropPiece(col int, playerColor int) bool { gi.persister.SaveGame(gi.game) } - gi.dirty = true + gi.notify() return true } - -func (gi *GameInstance) run() { - ticker := time.NewTicker(100 * time.Millisecond) - defer ticker.Stop() - - for { - select { - case pid := <-gi.leave: - gi.playersMu.Lock() - delete(gi.players, pid) - gi.playersMu.Unlock() - case <-ticker.C: - gi.publish() - case <-gi.done: - return - } - } -} - -func (gi *GameInstance) publish() { - gi.gameMu.Lock() - if !gi.dirty { - gi.gameMu.Unlock() - return - } - gi.dirty = false - - gi.playersMu.RLock() - syncers := make([]Syncable, 0, len(gi.players)) - for _, sync := range gi.players { - syncers = append(syncers, sync) - } - gi.playersMu.RUnlock() - gi.gameMu.Unlock() - - for _, sync := range syncers { - sync.Sync() - } -} diff --git a/go.mod b/go.mod index b268d65..ec3a0e1 100644 --- a/go.mod +++ b/go.mod @@ -15,12 +15,22 @@ require ( github.com/alexedwards/scs/sqlite3store v0.0.0-20251002162104-209de6e426de // indirect github.com/alexedwards/scs/v2 v2.9.0 // indirect github.com/andybalholm/brotli v1.2.0 // indirect + github.com/antithesishq/antithesis-sdk-go v0.5.0 // indirect + github.com/cenkalti/backoff v2.2.1+incompatible // indirect + github.com/delaneyj/toolbelt v0.9.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/go-tpm v0.9.7 // indirect github.com/hookenz/gotailwind/v4 v4.1.18 // indirect github.com/klauspost/compress v1.18.2 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mfridman/interpolate v0.0.2 // indirect + github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76 // indirect + github.com/nats-io/jwt/v2 v2.8.0 // indirect + github.com/nats-io/nats-server/v2 v2.12.2 // indirect + github.com/nats-io/nats.go v1.48.0 // indirect + github.com/nats-io/nkeys v0.4.12 // indirect + github.com/nats-io/nuid v1.0.1 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rs/zerolog v1.34.0 // indirect @@ -28,9 +38,10 @@ require ( github.com/starfederation/datastar-go v1.0.3 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect - golang.org/x/sync v0.17.0 // indirect + golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 // indirect + golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.40.0 // indirect + golang.org/x/time v0.14.0 // indirect maragu.dev/gomponents v1.2.0 // indirect modernc.org/libc v1.67.4 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index 5dc791a..f0139b2 100644 --- a/go.sum +++ b/go.sum @@ -7,16 +7,24 @@ github.com/alexedwards/scs/v2 v2.9.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gv github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/antithesishq/antithesis-sdk-go v0.5.0 h1:cudCFF83pDDANcXFzkQPUHHedfnnIbUO3JMr9fqwFJs= +github.com/antithesishq/antithesis-sdk-go v0.5.0/go.mod h1:IUpT2DPAKh6i/YhSbt6Gl3v2yvUZjmKncl7U91fup7E= +github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= +github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/delaneyj/toolbelt v0.9.1 h1:QJComn2qoaQ4azl5uRkGpdHSO9e+JtoxDTXCiQHvH8o= +github.com/delaneyj/toolbelt v0.9.1/go.mod h1:eNXpPuThjTD4tpRNCBl4JEz9jdg9LpyzNuyG+stnIbs= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/google/brotli/go/cbrotli v0.0.0-20230829110029-ed738e842d2f h1:jopqB+UTSdJGEJT8tEqYyE29zN91fi2827oLET8tl7k= github.com/google/brotli/go/cbrotli v0.0.0-20230829110029-ed738e842d2f/go.mod h1:nOPhAkwVliJdNTkj3gXpljmWhjc4wCaVqbMJcPKWP4s= +github.com/google/go-tpm v0.9.7 h1:u89J4tUUeDTlH8xxC3CTW7OHZjbjKoHdQ9W7gCUhtxA= +github.com/google/go-tpm v0.9.7/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -40,6 +48,18 @@ github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuE github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= +github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76 h1:KGuD/pM2JpL9FAYvBrnBBeENKZNh6eNtjqytV6TYjnk= +github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= +github.com/nats-io/jwt/v2 v2.8.0 h1:K7uzyz50+yGZDO5o772eRE7atlcSEENpL7P+b74JV1g= +github.com/nats-io/jwt/v2 v2.8.0/go.mod h1:me11pOkwObtcBNR8AiMrUbtVOUGkqYjMQZ6jnSdVUIA= +github.com/nats-io/nats-server/v2 v2.12.2 h1:4TEQd0Y4zvcW0IsVxjlXnRso1hBkQl3TS0BI+SxgPhE= +github.com/nats-io/nats-server/v2 v2.12.2/go.mod h1:j1AAttYeu7WnvD8HLJ+WWKNMSyxsqmZ160pNtCQRMyE= +github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U= +github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= +github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc= +github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= @@ -81,19 +101,22 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= -golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= -golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= -golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= -golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 h1:DHNhtq3sNNzrvduZZIiFyXWOL9IWaDPHqTnLJp+rCBY= +golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0= +golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= +golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= -golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= +golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/main.go b/main.go index d3c14a7..8f6fc32 100644 --- a/main.go +++ b/main.go @@ -15,6 +15,7 @@ import ( "github.com/ryanhamamura/c4/ui" "github.com/ryanhamamura/via" "github.com/ryanhamamura/via/h" + "github.com/ryanhamamura/via/vianats" ) var store = game.NewGameStore() @@ -43,12 +44,20 @@ func main() { log.Fatal(err) } + ctx := context.Background() + ns, err := vianats.New(ctx, "./data/nats") + if err != nil { + log.Fatal(err) + } + store.SetPubSub(ns) + v := via.New() v.Config(via.Options{ LogLevel: via.LogLevelDebug, DocumentTitle: "Connect 4", ServerAddress: ":7331", SessionManager: sessionManager, + PubSub: ns, Plugins: []via.Plugin{DaisyUIPlugin}, }) @@ -307,7 +316,6 @@ func main() { } gi.Join(&game.PlayerSession{ Player: player, - Sync: c, }) } c.Sync() @@ -336,6 +344,11 @@ func main() { } }) + // Subscribe to game updates so the opponent's moves trigger a re-render + if gameExists { + c.Subscribe("game."+gameID, func(data []byte) { c.Sync() }) + } + // If nickname exists in session and game exists, join immediately if gameExists && sessionNickname != "" && gi.GetPlayerColor(playerID) == 0 { player := &game.Player{ @@ -347,11 +360,7 @@ func main() { } gi.Join(&game.PlayerSession{ Player: player, - Sync: c, }) - } else if gameExists && gi.GetPlayerColor(playerID) != 0 { - // Re-register sync context for existing players on reconnect - gi.RegisterSync(playerID, c) } c.View(func() h.H {