diff --git a/deployment.yaml b/deployment.yaml index 8468f7f..324c89c 100644 --- a/deployment.yaml +++ b/deployment.yaml @@ -48,7 +48,7 @@ spec: spec: containers: - name: flowstatesrv - image: docker.io/makasim/flowstate:e662c53f + image: docker.io/makasim/flowstate:372320ea ports: - containerPort: 9282 env: diff --git a/go.mod b/go.mod index 91edd9b..f2e61bc 100644 --- a/go.mod +++ b/go.mod @@ -5,19 +5,20 @@ go 1.24.0 toolchain go1.24.3 require ( - connectrpc.com/connect v1.18.1 - github.com/makasim/flowstate v0.0.0-20250726121746-e662c53f5288 + connectrpc.com/connect v1.19.0 + github.com/VictoriaMetrics/easyproto v0.1.4 + github.com/makasim/flowstate v0.0.0-20250928165947-4916d95f4b01 + github.com/oklog/ulid/v2 v2.1.1 github.com/otrego/clamshell v0.0.0-20220814024334-043dd78cf746 github.com/rs/cors v1.11.1 - golang.org/x/net v0.38.0 - google.golang.org/protobuf v1.36.6 + golang.org/x/net v0.44.0 + google.golang.org/protobuf v1.36.9 ) require ( - github.com/VictoriaMetrics/easyproto v0.1.4 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/oklog/ulid/v2 v2.1.1 // indirect - golang.org/x/text v0.23.0 // indirect + golang.org/x/text v0.29.0 // indirect golang.org/x/time v0.6.0 // indirect ) diff --git a/go.sum b/go.sum index 43df5b7..13b4fc0 100644 --- a/go.sum +++ b/go.sum @@ -1,15 +1,21 @@ connectrpc.com/connect v1.18.1 h1:PAg7CjSAGvscaf6YZKUefjoih5Z/qYkyaTrBW8xvYPw= connectrpc.com/connect v1.18.1/go.mod h1:0292hj1rnx8oFrStN7cB4jjVBeqs+Yx5yDIC2prWDO8= +connectrpc.com/connect v1.19.0 h1:LuqUbq01PqbtL0o7vn0WMRXzR2nNsiINe5zfcJ24pJM= +connectrpc.com/connect v1.19.0/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= github.com/VictoriaMetrics/easyproto v0.1.4 h1:r8cNvo8o6sR4QShBXQd1bKw/VVLSQma/V2KhTBPf+Sc= github.com/VictoriaMetrics/easyproto v0.1.4/go.mod h1:QlGlzaJnDfFd8Lk6Ci/fuLxfTo3/GThPs2KH23mv710= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/gorhill/cronexpr v0.0.0-20180427100037-88b0669f7d75 h1:f0n1xnMSmBLzVfsMMvriDyA75NB/oBgILX2GcHXIQzY= github.com/gorhill/cronexpr v0.0.0-20180427100037-88b0669f7d75/go.mod h1:g2644b03hfBX9Ov0ZBDgXXens4rxSxmqFBbhvKv2yVA= -github.com/makasim/flowstate v0.0.0-20250726121746-e662c53f5288 h1:ajU8iOGqcXWIL4FHaU9bIVZYiBHNYb3ZlObu+ewT4SQ= -github.com/makasim/flowstate v0.0.0-20250726121746-e662c53f5288/go.mod h1:576Odv1ZCb4I12pDq72WbZrwLegxzh5xpc1Fe0KDdIw= +github.com/makasim/flowstate v0.0.0-20250928112803-eb5364b83eb4 h1:03a5XK93tbmw6aPOQWLllclY/pMbLuzeoke7OZAXxIM= +github.com/makasim/flowstate v0.0.0-20250928112803-eb5364b83eb4/go.mod h1:Q2DpFG+Wx13WP4Vj5e7BilfRKa0cWrm21cYNzWg1zsw= +github.com/makasim/flowstate v0.0.0-20250928165947-4916d95f4b01 h1:OhhDaqVVlixyCbhhPSTvU4mYAHH4n6Mu5kSg0xYpmDw= +github.com/makasim/flowstate v0.0.0-20250928165947-4916d95f4b01/go.mod h1:Q2DpFG+Wx13WP4Vj5e7BilfRKa0cWrm21cYNzWg1zsw= github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= github.com/otrego/clamshell v0.0.0-20220814024334-043dd78cf746 h1:lRPeFeuZAdclG9NZudMoF5T+sImSK/YqGI7spKR7q/U= @@ -27,11 +33,16 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= +golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= +google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/api/convertor/data.go b/internal/api/convertor/data.go index 748d4a0..3c665c1 100644 --- a/internal/api/convertor/data.go +++ b/internal/api/convertor/data.go @@ -12,14 +12,13 @@ func GameToData(g *v1.Game, d *flowstate.Data) error { return err } - d.ID = flowstate.DataID(g.Id) - d.B = b + d.Blob = b return nil } func DataToGame(d *flowstate.Data) (*v1.Game, error) { g := &v1.Game{} - if err := protojson.Unmarshal(d.B, g); err != nil { + if err := protojson.Unmarshal(d.Blob, g); err != nil { return nil, err } @@ -27,16 +26,17 @@ func DataToGame(d *flowstate.Data) (*v1.Game, error) { } func FindGame(e flowstate.Engine, gID string, gRev int32) (*v1.Game, *flowstate.StateCtx, *flowstate.Data, error) { - d := &flowstate.Data{} stateCtx := &flowstate.StateCtx{} if err := e.Do( flowstate.GetStateByID(stateCtx, flowstate.StateID(gID), int64(gRev)), - flowstate.GetData(stateCtx, d, `game`), + flowstate.GetData(stateCtx, `game`), ); err != nil { return nil, nil, nil, err } + d := stateCtx.MustData(`game`) + g, err := DataToGame(d) if err != nil { return nil, nil, nil, err diff --git a/internal/api/convertor/undo.go b/internal/api/convertor/undo.go index cfa788d..50d12fa 100644 --- a/internal/api/convertor/undo.go +++ b/internal/api/convertor/undo.go @@ -1,8 +1,6 @@ package convertor import ( - "fmt" - "github.com/makasim/flowstate" v1 "github.com/makasim/gogame/protogen/gogame/v1" "google.golang.org/protobuf/encoding/protojson" @@ -14,14 +12,14 @@ func UndoToData(u *v1.Undo, d *flowstate.Data) error { return err } - d.ID = flowstate.DataID(fmt.Sprintf("%s-%d", u.GameId, u.GameRev)) - d.B = b + //d.ID = flowstate.DataID(fmt.Sprintf("%s-%d", u.GameId, u.GameRev)) + d.Blob = b return nil } func DataToUndo(d *flowstate.Data) (*v1.Undo, error) { u := &v1.Undo{} - if err := protojson.Unmarshal(d.B, u); err != nil { + if err := protojson.Unmarshal(d.Blob, u); err != nil { return nil, err } diff --git a/internal/api/corsmiddleware/cors.go b/internal/api/corsmiddleware/cors.go deleted file mode 100644 index 84bef4b..0000000 --- a/internal/api/corsmiddleware/cors.go +++ /dev/null @@ -1,37 +0,0 @@ -package corsmiddleware - -import ( - "net/http" - - "github.com/rs/cors" -) - -type MW struct { - enabled bool -} - -func New(enabled bool) *MW { - return &MW{ - enabled: enabled, - } -} - -func (mv *MW) Wrap(h http.Handler) http.Handler { - if !mv.enabled { - return h - } - - c := cors.New(cors.Options{ - AllowedOrigins: []string{`*`}, - AllowedMethods: []string{`POST`, `GET`}, - AllowedHeaders: []string{`*`}, - AllowCredentials: true, - MaxAge: 600, - }) - - return c.Handler(h) -} - -func (mv *MW) WrapPath(path string, h http.Handler) (string, http.Handler) { - return path, mv.Wrap(h) -} diff --git a/internal/api/gameservicev1/service.go b/internal/api/gameservicev1/service.go index 04242b9..7947153 100644 --- a/internal/api/gameservicev1/service.go +++ b/internal/api/gameservicev1/service.go @@ -10,14 +10,6 @@ import ( var _ gogamev1connect.GameServiceHandler = (*Service)(nil) -type createGameHandler interface { - CreateGame(_ context.Context, req *connect.Request[v1.CreateGameRequest]) (*connect.Response[v1.CreateGameResponse], error) -} - -type joinGameHandler interface { - JoinGame(_ context.Context, req *connect.Request[v1.JoinGameRequest]) (*connect.Response[v1.JoinGameResponse], error) -} - type streamVacantGamesHandler interface { StreamVacantGames(ctx context.Context, req *connect.Request[v1.StreamVacantGamesRequest], stream *connect.ServerStream[v1.StreamVacantGamesResponse]) error } @@ -26,61 +18,27 @@ type streamGameEventsHandler interface { StreamGameEvents(context.Context, *connect.Request[v1.StreamGameEventsRequest], *connect.ServerStream[v1.StreamGameEventsResponse]) error } -type makeMoveHandler interface { - MakeMove(_ context.Context, req *connect.Request[v1.MakeMoveRequest]) (*connect.Response[v1.MakeMoveResponse], error) -} - -type resignHandler interface { - Resign(context.Context, *connect.Request[v1.ResignRequest]) (*connect.Response[v1.ResignResponse], error) -} - -type passHandler interface { - Pass(context.Context, *connect.Request[v1.PassRequest]) (*connect.Response[v1.PassResponse], error) -} - -type undoHandler interface { - Undo(_ context.Context, req *connect.Request[v1.UndoRequest]) (*connect.Response[v1.UndoResponse], error) -} - type Service struct { - cgh createGameHandler - jgh joinGameHandler svgh streamVacantGamesHandler sgeh streamGameEventsHandler - mmh makeMoveHandler - rh resignHandler - ph passHandler - uh undoHandler } func New( - cgh createGameHandler, - jgh joinGameHandler, svgh streamVacantGamesHandler, sgeh streamGameEventsHandler, - mmh makeMoveHandler, - rh resignHandler, - ph passHandler, - uh undoHandler, ) *Service { return &Service{ - cgh: cgh, - jgh: jgh, svgh: svgh, sgeh: sgeh, - mmh: mmh, - rh: rh, - ph: ph, - uh: uh, } } func (s *Service) CreateGame(ctx context.Context, req *connect.Request[v1.CreateGameRequest]) (*connect.Response[v1.CreateGameResponse], error) { - return s.cgh.CreateGame(ctx, req) + panic("BUG: CreateGame must not be called") } func (s *Service) JoinGame(ctx context.Context, req *connect.Request[v1.JoinGameRequest]) (*connect.Response[v1.JoinGameResponse], error) { - return s.jgh.JoinGame(ctx, req) + panic("BUG: JoinGame must not be called") } func (s *Service) StreamVacantGames(ctx context.Context, req *connect.Request[v1.StreamVacantGamesRequest], stream *connect.ServerStream[v1.StreamVacantGamesResponse]) error { @@ -92,17 +50,17 @@ func (s *Service) StreamGameEvents(ctx context.Context, req *connect.Request[v1. } func (s *Service) MakeMove(ctx context.Context, req *connect.Request[v1.MakeMoveRequest]) (*connect.Response[v1.MakeMoveResponse], error) { - return s.mmh.MakeMove(ctx, req) + panic("BUG: MakeMove must not be called") } func (s *Service) Pass(ctx context.Context, req *connect.Request[v1.PassRequest]) (*connect.Response[v1.PassResponse], error) { - return s.ph.Pass(ctx, req) + panic("BUG: Pass must not be called") } func (s *Service) Resign(ctx context.Context, req *connect.Request[v1.ResignRequest]) (*connect.Response[v1.ResignResponse], error) { - return s.rh.Resign(ctx, req) + panic("BUG: Resign must not be called") } func (s *Service) Undo(ctx context.Context, req *connect.Request[v1.UndoRequest]) (*connect.Response[v1.UndoResponse], error) { - return s.uh.Undo(ctx, req) + panic("BUG: Undo must not be called") } diff --git a/internal/api/gameservicev1/streamgameeventshandler/handler.go b/internal/api/gameservicev1/streamgameeventshandler/handler.go index 154fc60..a7b5d47 100644 --- a/internal/api/gameservicev1/streamgameeventshandler/handler.go +++ b/internal/api/gameservicev1/streamgameeventshandler/handler.go @@ -43,19 +43,19 @@ func (h *Handler) StreamGameEvents(ctx context.Context, req *connect.Request[v1. gID := state.Annotations[`game.id`] gRev, _ := strconv.ParseInt(state.Annotations[`game.rev`], 10, 0) - d := &flowstate.Data{} stateCtx := &flowstate.StateCtx{} - undoStateCtx := state.CopyToCtx(&flowstate.StateCtx{}) - undoD := &flowstate.Data{} if err := h.e.Do( flowstate.GetStateByID(stateCtx, flowstate.StateID(gID), gRev), - flowstate.GetData(stateCtx, d, `game`), - flowstate.GetData(undoStateCtx, undoD, `undo`), + flowstate.GetData(stateCtx, `game`), + flowstate.GetData(undoStateCtx, `undo`), ); err != nil { continue } + d := stateCtx.MustData(`game`) + undoD := undoStateCtx.MustData(`undo`) + u, err := convertor.DataToUndo(undoD) if err != nil { continue diff --git a/internal/api/gameservicev1/undohandler/handler.go b/internal/api/gameservicev1/undohandler/handler.go index cb2db1e..5663fc9 100644 --- a/internal/api/gameservicev1/undohandler/handler.go +++ b/internal/api/gameservicev1/undohandler/handler.go @@ -23,145 +23,5 @@ func New(e flowstate.Engine) *Handler { } func (h *Handler) Undo(_ context.Context, req *connect.Request[v1.UndoRequest]) (*connect.Response[v1.UndoResponse], error) { - if req.Msg.GameId == `` { - return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("game id is required")) - } - if req.Msg.GameRev <= 0 { - return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("game rev is required")) - } - - g, stateCtx, d, err := convertor.FindGame(h.e, req.Msg.GameId, req.Msg.GameRev) - if err != nil { - return nil, connect.NewError(connect.CodeInternal, err) - } - if len(g.PreviousMoves) == 0 { - return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("no moves to undo")) - } - - m := g.PreviousMoves[len(g.PreviousMoves)-1] - - switch { - case req.Msg.GetRequest() != nil: - undoReq := req.Msg.GetRequest() - - if undoReq.PlayerId != m.PlayerId { - return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("cannot undo other player's move")) - } - - u := &v1.Undo{ - GameId: g.Id, - GameRev: g.Rev, - PlayerId: m.PlayerId, - Move: int32(len(g.PreviousMoves)), - } - - undoD := &flowstate.Data{} - if err := convertor.UndoToData(u, undoD); err != nil { - return nil, connect.NewError(connect.CodeInternal, err) - } - - undoStateCtx := &flowstate.StateCtx{ - Current: flowstate.State{ - ID: flowstate.StateID(fmt.Sprintf(`undo-%s-%d`, g.Id, g.Rev)), - Labels: map[string]string{ - `undo.game.id`: g.Id, - }, - Annotations: map[string]string{ - `game.id`: g.Id, - `game.rev`: fmt.Sprintf(`%d`, g.Rev), - }, - }, - } - - if err := h.e.Do(flowstate.Commit( - flowstate.AttachData(undoStateCtx, undoD, `undo`), - flowstate.Park(undoStateCtx), - )); err != nil { - return nil, connect.NewError(connect.CodeInternal, err) - } - - return connect.NewResponse(&v1.UndoResponse{ - Game: g, - Undo: u, - }), nil - case req.Msg.GetDecision() != nil: - undoDecision := req.Msg.GetDecision() - if undoDecision.PlayerId == m.PlayerId { - return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("cannot decide on own undo")) - } - - undoD := &flowstate.Data{} - undoStateCtx := &flowstate.StateCtx{} - if err := h.e.Do( - flowstate.GetStateByID(undoStateCtx, flowstate.StateID(fmt.Sprintf(`undo-%s-%d`, g.Id, g.Rev)), 0), - flowstate.GetData(undoStateCtx, undoD, `undo`), - ); err != nil { - return nil, connect.NewError(connect.CodeInternal, err) - } - - undo, err := convertor.DataToUndo(undoD) - if err != nil { - return nil, connect.NewError(connect.CodeInternal, err) - } - - if undo.Decided { - return connect.NewResponse(&v1.UndoResponse{ - Undo: undo, - }), nil - } - - undo.Accepted = undoDecision.Accepted - undo.Decided = true - if err := convertor.UndoToData(undo, undoD); err != nil { - return nil, connect.NewError(connect.CodeInternal, err) - } - - if !undo.Accepted { - if err := h.e.Do(flowstate.Commit( - flowstate.AttachData(undoStateCtx, undoD, `undo`), - flowstate.Park(undoStateCtx), - )); err != nil { - return nil, connect.NewError(connect.CodeInternal, err) - } - - return connect.NewResponse(&v1.UndoResponse{ - Undo: undo, - }), nil - - } - - m.Undone = true - g.CurrentMove = &v1.Move{ - PlayerId: m.PlayerId, - Color: m.Color, - EndAt: time.Now().Add(time.Duration(g.MoveDurationSec) * time.Second).Unix(), - } - b, err := convertor.GameToBoard(g) - if err != nil { - return nil, connect.NewError(connect.CodeInternal, err) - } - g.Board = convertor.FromClamBoard(b) - - if err := convertor.GameToData(g, d); err != nil { - return nil, connect.NewError(connect.CodeInternal, err) - } - - if err := h.e.Do(flowstate.Commit( - flowstate.AttachData(undoStateCtx, undoD, `undo`), - flowstate.AttachData(stateCtx, d, `game`), - flowstate.Park(undoStateCtx), - flowstate.Park(stateCtx), - flowstate.Delay(stateCtx, movetimeoutflow.ID, time.Duration(g.MoveDurationSec)*time.Second), - )); err != nil { - return nil, connect.NewError(connect.CodeInternal, err) - } - - return connect.NewResponse(&v1.UndoResponse{ - Game: g, - Undo: undo, - }), nil - default: - return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("invalid request")) - } } diff --git a/internal/api/handlers.go b/internal/api/handlers.go new file mode 100644 index 0000000..d169338 --- /dev/null +++ b/internal/api/handlers.go @@ -0,0 +1,135 @@ +package api + +import ( + "io" + "log/slog" + "net/http" + + "github.com/makasim/flowstate" + "github.com/makasim/gogame/internal/creategameflow" + "github.com/makasim/gogame/internal/joingameflow" + "github.com/makasim/gogame/internal/makemoveflow" + "github.com/makasim/gogame/internal/passflow" + "github.com/makasim/gogame/internal/promutil" + "github.com/makasim/gogame/internal/resignflow" + "github.com/makasim/gogame/internal/undoflow" + "github.com/oklog/ulid/v2" +) + +func HandleAll(rw http.ResponseWriter, r *http.Request, e flowstate.Engine, l *slog.Logger) bool { + if HandleCreateGame(rw, r, e, l) { + return true + } + if HandleJoinGame(rw, r, e, l) { + return true + } + if HandleMakeMove(rw, r, e, l) { + return true + } + if HandleResign(rw, r, e, l) { + return true + } + if HandlePass(rw, r, e, l) { + return true + } + if HandleUndo(rw, r, e, l) { + return true + } + + return false +} + +func HandleCreateGame(rw http.ResponseWriter, r *http.Request, e flowstate.Engine, l *slog.Logger) bool { + if r.URL.Path != "/gogame.v1.GameService/CreateGame" { + return false + } + + res := handleFlow(rw, r, e, creategameflow.ID, l) + return res +} + +func HandleJoinGame(rw http.ResponseWriter, r *http.Request, e flowstate.Engine, l *slog.Logger) bool { + if r.URL.Path != "/gogame.v1.GameService/JoinGame" { + return false + } + + res := handleFlow(rw, r, e, joingameflow.ID, l) + return res +} + +func HandleMakeMove(rw http.ResponseWriter, r *http.Request, e flowstate.Engine, l *slog.Logger) bool { + if r.URL.Path != "/gogame.v1.GameService/MakeMove" { + return false + } + + res := handleFlow(rw, r, e, makemoveflow.ID, l) + return res +} + +func HandleResign(rw http.ResponseWriter, r *http.Request, e flowstate.Engine, l *slog.Logger) bool { + if r.URL.Path != "/gogame.v1.GameService/Resign" { + return false + } + + res := handleFlow(rw, r, e, resignflow.ID, l) + return res +} + +func HandlePass(rw http.ResponseWriter, r *http.Request, e flowstate.Engine, l *slog.Logger) bool { + if r.URL.Path != "/gogame.v1.GameService/Pass" { + return false + } + + res := handleFlow(rw, r, e, passflow.ID, l) + return res +} + +func HandleUndo(rw http.ResponseWriter, r *http.Request, e flowstate.Engine, l *slog.Logger) bool { + if r.URL.Path != "/gogame.v1.GameService/Undo" { + return false + } + + res := handleFlow(rw, r, e, undoflow.ID, l) + return res +} + +func handleFlow(rw http.ResponseWriter, r *http.Request, e flowstate.Engine, fID flowstate.FlowID, _ *slog.Logger) bool { + proto := r.Header.Get("Content-Type") != "application/json" + + b, err := io.ReadAll(r.Body) + if err != nil { + promutil.WriteInvalidArgumentError(rw, "failed to read request body: "+err.Error(), proto) + return true + } + + stateCtx := &flowstate.StateCtx{ + Current: flowstate.State{ + ID: flowstate.StateID(ulid.Make().String()), + Transition: flowstate.Transition{ + To: fID, + }, + }, + Datas: map[string]*flowstate.Data{ + "request": { + Annotations: map[string]string{ + "content-type": r.Header.Get("Content-Type"), + }, + Blob: b, + }, + }, + } + + if err := e.Execute(stateCtx); err != nil { + promutil.WriteError(rw, err, proto) + return true + } + + respData, err := stateCtx.Data("response") + if err != nil { + promutil.WriteUnknownError(rw, err.Error(), proto) + return true + } + + promutil.WriteOK(rw, respData) + return true +} diff --git a/internal/app/app.go b/internal/app/app.go index 2512f72..af91cd0 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -13,20 +13,21 @@ import ( "github.com/makasim/flowstate" "github.com/makasim/flowstate/netdriver" "github.com/makasim/flowstate/netflow" - "github.com/makasim/gogame/internal/api/corsmiddleware" + "github.com/makasim/gogame/internal/api" "github.com/makasim/gogame/internal/api/gameservicev1" - "github.com/makasim/gogame/internal/api/gameservicev1/creategamehandler" - "github.com/makasim/gogame/internal/api/gameservicev1/joingamehandler" - "github.com/makasim/gogame/internal/api/gameservicev1/makemovehandler" - "github.com/makasim/gogame/internal/api/gameservicev1/passhandler" - "github.com/makasim/gogame/internal/api/gameservicev1/resignhandler" "github.com/makasim/gogame/internal/api/gameservicev1/streamgameeventshandler" "github.com/makasim/gogame/internal/api/gameservicev1/streamvacantgameshandler" - "github.com/makasim/gogame/internal/api/gameservicev1/undohandler" + "github.com/makasim/gogame/internal/creategameflow" + "github.com/makasim/gogame/internal/joingameflow" + "github.com/makasim/gogame/internal/makemoveflow" "github.com/makasim/gogame/internal/movetimeoutflow" + "github.com/makasim/gogame/internal/passflow" + "github.com/makasim/gogame/internal/resignflow" "github.com/makasim/gogame/internal/staleflow" + "github.com/makasim/gogame/internal/undoflow" "github.com/makasim/gogame/protogen/gogame/v1/gogamev1connect" "github.com/makasim/gogame/ui" + "github.com/rs/cors" "golang.org/x/net/http2" "golang.org/x/net/http2/h2c" ) @@ -68,6 +69,24 @@ func (a *App) Run(ctx context.Context) error { fr := netflow.NewRegistry(httpHost, d, a.l) defer fr.Close() + if err := fr.SetFlow(creategameflow.New()); err != nil { + return fmt.Errorf("flow registry: set flow: creategameflow: %w", err) + } + if err := fr.SetFlow(joingameflow.New()); err != nil { + return fmt.Errorf("flow registry: set flow: joingameflow: %w", err) + } + if err := fr.SetFlow(makemoveflow.New()); err != nil { + return fmt.Errorf("flow registry: set flow: makemoveflow: %w", err) + } + if err := fr.SetFlow(resignflow.New()); err != nil { + return fmt.Errorf("flow registry: set flow: resignflow: %w", err) + } + if err := fr.SetFlow(passflow.New()); err != nil { + return fmt.Errorf("flow registry: set flow: passflow: %w", err) + } + if err := fr.SetFlow(undoflow.New()); err != nil { + return fmt.Errorf("flow registry: set flow: undoflow: %w", err) + } if err := fr.SetFlow(movetimeoutflow.New()); err != nil { return fmt.Errorf("set flow move: %w", err) } @@ -80,32 +99,26 @@ func (a *App) Run(ctx context.Context) error { return fmt.Errorf("new engine: %w", err) } - corsEnv := os.Getenv(`CORS_ENABLED`) - corsMW := corsmiddleware.New(corsEnv == `true` || corsEnv == ``) - mux := http.NewServeMux() - mux.Handle(corsMW.WrapPath(gogamev1connect.NewGameServiceHandler(gameservicev1.New( - creategamehandler.New(e), - joingamehandler.New(e), + mux.Handle(gogamev1connect.NewGameServiceHandler(gameservicev1.New( streamvacantgameshandler.New(e), streamgameeventshandler.New(e), - makemovehandler.New(e), - resignhandler.New(e), - passhandler.New(e), - undohandler.New(e), - )))) + ))) - mux.Handle("/", corsMW.Wrap(http.FileServerFS(ui.PublicFS()))) + mux.Handle("/", http.FileServerFS(ui.PublicFS())) srv := &http.Server{ Addr: `0.0.0.0:8181`, - Handler: h2c.NewHandler(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + Handler: h2c.NewHandler(handleCORS(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { if netflow.HandleExecute(rw, r, e) { return } + if api.HandleAll(rw, r, e, a.l) { + return + } mux.ServeHTTP(rw, r) - }), &http2.Server{}), + })), &http2.Server{}), } go func() { @@ -132,3 +145,13 @@ func (a *App) Run(ctx context.Context) error { return shutdownRes } + +func handleCORS(h http.Handler) http.Handler { + return cors.New(cors.Options{ + AllowedOrigins: []string{`*`}, + AllowedMethods: []string{`POST`, `GET`}, + AllowedHeaders: []string{`*`}, + AllowCredentials: true, + MaxAge: 600, + }).Handler(h) +} diff --git a/internal/api/gameservicev1/creategamehandler/handler.go b/internal/creategameflow/flow.go similarity index 59% rename from internal/api/gameservicev1/creategamehandler/handler.go rename to internal/creategameflow/flow.go index 7e326f4..764f3f2 100644 --- a/internal/api/gameservicev1/creategamehandler/handler.go +++ b/internal/creategameflow/flow.go @@ -1,7 +1,6 @@ -package creategamehandler +package creategameflow import ( - "context" "fmt" "strconv" "time" @@ -9,34 +8,38 @@ import ( "connectrpc.com/connect" "github.com/makasim/flowstate" "github.com/makasim/gogame/internal/api/convertor" + "github.com/makasim/gogame/internal/promutil" "github.com/makasim/gogame/internal/staleflow" v1 "github.com/makasim/gogame/protogen/gogame/v1" ) -type Handler struct { - e flowstate.Engine +var ID flowstate.FlowID = `gogame.create_game` + +type Flow struct { } -func New(e flowstate.Engine) *Handler { - return &Handler{ - e: e, - } +func New() (flowstate.FlowID, *Flow) { + return ID, &Flow{} } -func (h *Handler) CreateGame(_ context.Context, req *connect.Request[v1.CreateGameRequest]) (*connect.Response[v1.CreateGameResponse], error) { - if req.Msg.Name == `` { +func (f *Flow) Execute(reqStateCtx *flowstate.StateCtx, e flowstate.Engine) (flowstate.Command, error) { + msg := &v1.CreateGameRequest{} + if err := promutil.UnmarshalRequest(reqStateCtx, msg); err != nil { + return nil, err + } + if msg.Name == `` { return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("game name is required")) } - if req.Msg.Player1 != nil && req.Msg.Player1.Name == `` { + if msg.Player1 != nil && msg.Player1.Name == `` { return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("player1 name is required")) } g := &v1.Game{ Id: strconv.FormatInt(time.Now().UnixNano(), 10), - Name: req.Msg.Name, - Player1: req.Msg.Player1, + Name: msg.Name, + Player1: msg.Player1, State: v1.State_STATE_CREATED, - MoveDurationSec: req.Msg.MoveDurationSec, + MoveDurationSec: msg.MoveDurationSec, } if g.MoveDurationSec == 0 { g.MoveDurationSec = 60 @@ -56,10 +59,13 @@ func (h *Handler) CreateGame(_ context.Context, req *connect.Request[v1.CreateGa `game.state`: `created`, }, }, + Datas: map[string]*flowstate.Data{ + "game": d, + }, } - if err := h.e.Do(flowstate.Commit( - flowstate.AttachData(stateCtx, d, `game`), + if err := e.Do(flowstate.Commit( + flowstate.StoreData(stateCtx, `game`), flowstate.Park(stateCtx), flowstate.Delay(stateCtx, staleflow.ID, time.Minute), )); err != nil { @@ -68,7 +74,7 @@ func (h *Handler) CreateGame(_ context.Context, req *connect.Request[v1.CreateGa g.Rev = int32(stateCtx.Current.Rev) - return connect.NewResponse(&v1.CreateGameResponse{ + return flowstate.Noop(), promutil.MarshalResponse(reqStateCtx, &v1.CreateGameResponse{ Game: g, - }), nil + }) } diff --git a/internal/api/gameservicev1/joingamehandler/handler.go b/internal/joingameflow/flow.go similarity index 69% rename from internal/api/gameservicev1/joingamehandler/handler.go rename to internal/joingameflow/flow.go index 6a83c49..fd147b6 100644 --- a/internal/api/gameservicev1/joingamehandler/handler.go +++ b/internal/joingameflow/flow.go @@ -1,7 +1,6 @@ -package joingamehandler +package joingameflow import ( - "context" "fmt" "math/rand" "time" @@ -10,29 +9,33 @@ import ( "github.com/makasim/flowstate" "github.com/makasim/gogame/internal/api/convertor" "github.com/makasim/gogame/internal/movetimeoutflow" + "github.com/makasim/gogame/internal/promutil" v1 "github.com/makasim/gogame/protogen/gogame/v1" "github.com/otrego/clamshell/go/board" ) -type Handler struct { - e flowstate.Engine +var ID flowstate.FlowID = `gogame.join_game` + +type Flow struct { } -func New(e flowstate.Engine) *Handler { - return &Handler{ - e: e, - } +func New() (flowstate.FlowID, *Flow) { + return ID, &Flow{} } -func (h *Handler) JoinGame(_ context.Context, req *connect.Request[v1.JoinGameRequest]) (*connect.Response[v1.JoinGameResponse], error) { - if req.Msg.GameId == `` { +func (f *Flow) Execute(reqStateCtx *flowstate.StateCtx, e flowstate.Engine) (flowstate.Command, error) { + msg := &v1.JoinGameRequest{} + if err := promutil.UnmarshalRequest(reqStateCtx, msg); err != nil { + return nil, err + } + if msg.GameId == `` { return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("game id is required")) } - if req.Msg.Player2.Name == `` { + if msg.Player2.Name == `` { return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("player2 name is required")) } - g, stateCtx, d, err := convertor.FindGame(h.e, req.Msg.GameId, 0) + g, stateCtx, d, err := convertor.FindGame(e, msg.GameId, 0) if err != nil { return nil, connect.NewError(connect.CodeInternal, err) } @@ -40,13 +43,13 @@ func (h *Handler) JoinGame(_ context.Context, req *connect.Request[v1.JoinGameRe if stateCtx.Current.Labels[`game.state`] != `created` { return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("game is not joinable")) } - if g.Player1.Id == req.Msg.Player2.Id { + if g.Player1.Id == msg.Player2.Id { return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("player1 and player2 are the same")) } stateCtx.Current.SetLabel(`game.state`, `started`) - g.Player2 = req.Msg.Player2 + g.Player2 = msg.Player2 g.State = v1.State_STATE_STARTED chooseFirstMove(g) @@ -56,8 +59,8 @@ func (h *Handler) JoinGame(_ context.Context, req *connect.Request[v1.JoinGameRe return nil, connect.NewError(connect.CodeInternal, err) } - if err := h.e.Do(flowstate.Commit( - flowstate.AttachData(stateCtx, d, `game`), + if err := e.Do(flowstate.Commit( + flowstate.StoreData(stateCtx, `game`), flowstate.Park(stateCtx), flowstate.Delay(stateCtx, movetimeoutflow.ID, time.Duration(g.MoveDurationSec)*time.Second), )); err != nil { @@ -66,9 +69,9 @@ func (h *Handler) JoinGame(_ context.Context, req *connect.Request[v1.JoinGameRe g.Rev = int32(stateCtx.Current.Rev) - return connect.NewResponse(&v1.JoinGameResponse{ + return flowstate.Noop(), promutil.MarshalResponse(reqStateCtx, &v1.JoinGameResponse{ Game: g, - }), nil + }) } func chooseFirstMove(g *v1.Game) { diff --git a/internal/api/gameservicev1/makemovehandler/handler.go b/internal/makemoveflow/flow.go similarity index 72% rename from internal/api/gameservicev1/makemovehandler/handler.go rename to internal/makemoveflow/flow.go index 928c3e5..da07d69 100644 --- a/internal/api/gameservicev1/makemovehandler/handler.go +++ b/internal/makemoveflow/flow.go @@ -1,7 +1,6 @@ -package makemovehandler +package makemoveflow import ( - "context" "fmt" "time" @@ -9,43 +8,47 @@ import ( "github.com/makasim/flowstate" "github.com/makasim/gogame/internal/api/convertor" "github.com/makasim/gogame/internal/movetimeoutflow" + "github.com/makasim/gogame/internal/promutil" v1 "github.com/makasim/gogame/protogen/gogame/v1" ) -type Handler struct { - e flowstate.Engine +var ID flowstate.FlowID = `gogame.make_move` + +type Flow struct { } -func New(e flowstate.Engine) *Handler { - return &Handler{ - e: e, - } +func New() (flowstate.FlowID, *Flow) { + return ID, &Flow{} } -func (h *Handler) MakeMove(_ context.Context, req *connect.Request[v1.MakeMoveRequest]) (*connect.Response[v1.MakeMoveResponse], error) { - if req.Msg.GameId == `` { +func (f *Flow) Execute(reqStateCtx *flowstate.StateCtx, e flowstate.Engine) (flowstate.Command, error) { + msg := &v1.MakeMoveRequest{} + if err := promutil.UnmarshalRequest(reqStateCtx, msg); err != nil { + return nil, err + } + if msg.GameId == `` { return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("game id is required")) } - if req.Msg.GameRev == 0 { + if msg.GameRev == 0 { return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("game rev is required")) } - if req.Msg.Move == nil { + if msg.Move == nil { return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("move is required")) } - if req.Msg.Move.PlayerId == `` { + if msg.Move.PlayerId == `` { return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("move player id is required")) } - if req.Msg.Move.Color <= 0 { + if msg.Move.Color <= 0 { return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("move color is required")) } - if req.Msg.Move.X < 0 { + if msg.Move.X < 0 { return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("move x is required")) } - if req.Msg.Move.Y < 0 { + if msg.Move.Y < 0 { return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("move y is required")) } - g, stateCtx, d, err := convertor.FindGame(h.e, req.Msg.GameId, req.Msg.GameRev) + g, stateCtx, d, err := convertor.FindGame(e, msg.GameId, msg.GameRev) if err != nil { return nil, connect.NewError(connect.CodeInternal, err) } @@ -53,7 +56,7 @@ func (h *Handler) MakeMove(_ context.Context, req *connect.Request[v1.MakeMoveRe if !(stateCtx.Current.Labels[`game.state`] == `started` || stateCtx.Current.Labels[`game.state`] == `move`) { return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("state is not move")) } - if g.CurrentMove.PlayerId != req.Msg.Move.PlayerId { + if g.CurrentMove.PlayerId != msg.Move.PlayerId { return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("not player's turn")) } @@ -65,8 +68,8 @@ func (h *Handler) MakeMove(_ context.Context, req *connect.Request[v1.MakeMoveRe nextMove := &v1.Move{ PlayerId: g.CurrentMove.PlayerId, Color: g.CurrentMove.Color, - X: req.Msg.Move.X, - Y: req.Msg.Move.Y, + X: msg.Move.X, + Y: msg.Move.Y, } l, err := b.PlaceStone(convertor.ToClamMove(nextMove)) @@ -91,8 +94,8 @@ func (h *Handler) MakeMove(_ context.Context, req *connect.Request[v1.MakeMoveRe return nil, connect.NewError(connect.CodeInternal, err) } - if err := h.e.Do(flowstate.Commit( - flowstate.AttachData(stateCtx, d, `game`), + if err := e.Do(flowstate.Commit( + flowstate.StoreData(stateCtx, `game`), flowstate.Park(stateCtx), flowstate.Delay(stateCtx, movetimeoutflow.ID, time.Duration(g.MoveDurationSec)*time.Second), )); err != nil { @@ -101,7 +104,7 @@ func (h *Handler) MakeMove(_ context.Context, req *connect.Request[v1.MakeMoveRe g.Rev = int32(stateCtx.Current.Rev) - return connect.NewResponse(&v1.MakeMoveResponse{ + return flowstate.Noop(), promutil.MarshalResponse(reqStateCtx, &v1.MakeMoveResponse{ Game: g, - }), nil + }) } diff --git a/internal/movetimeoutflow/flow.go b/internal/movetimeoutflow/flow.go index ee3ed59..dd204d0 100644 --- a/internal/movetimeoutflow/flow.go +++ b/internal/movetimeoutflow/flow.go @@ -16,14 +16,14 @@ func New() (flowstate.FlowID, *Flow) { } func (f *Flow) Execute(stateCtx *flowstate.StateCtx, e flowstate.Engine) (flowstate.Command, error) { - d := &flowstate.Data{} - if err := e.Do( - flowstate.GetData(stateCtx, d, `game`), + flowstate.GetData(stateCtx, `game`), ); err != nil { return nil, err } + d := stateCtx.MustData(`game`) + g, err := convertor.DataToGame(d) if err != nil { return nil, err @@ -41,7 +41,7 @@ func (f *Flow) Execute(stateCtx *flowstate.StateCtx, e flowstate.Engine) (flowst } return flowstate.Commit( - flowstate.AttachData(stateCtx, d, `game`), + flowstate.StoreData(stateCtx, `game`), flowstate.Park(stateCtx), ), nil } diff --git a/internal/api/gameservicev1/passhandler/handler.go b/internal/passflow/flow.go similarity index 71% rename from internal/api/gameservicev1/passhandler/handler.go rename to internal/passflow/flow.go index a721345..03c46d7 100644 --- a/internal/api/gameservicev1/passhandler/handler.go +++ b/internal/passflow/flow.go @@ -1,7 +1,6 @@ -package passhandler +package passflow import ( - "context" "fmt" "time" @@ -9,31 +8,35 @@ import ( "github.com/makasim/flowstate" "github.com/makasim/gogame/internal/api/convertor" "github.com/makasim/gogame/internal/movetimeoutflow" + "github.com/makasim/gogame/internal/promutil" v1 "github.com/makasim/gogame/protogen/gogame/v1" ) -type Handler struct { - e flowstate.Engine +var ID flowstate.FlowID = `gogame.pass` + +type Flow struct { } -func New(e flowstate.Engine) *Handler { - return &Handler{ - e: e, - } +func New() (flowstate.FlowID, *Flow) { + return ID, &Flow{} } -func (h *Handler) Pass(_ context.Context, req *connect.Request[v1.PassRequest]) (*connect.Response[v1.PassResponse], error) { - if req.Msg.GameId == `` { +func (f *Flow) Execute(reqStateCtx *flowstate.StateCtx, e flowstate.Engine) (flowstate.Command, error) { + msg := &v1.PassRequest{} + if err := promutil.UnmarshalRequest(reqStateCtx, msg); err != nil { + return nil, err + } + if msg.GameId == `` { return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("game id is required")) } - if req.Msg.GameRev == 0 { + if msg.GameRev == 0 { return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("game rev is required")) } - if req.Msg.PlayerId == `` { + if msg.PlayerId == `` { return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("move player id is required")) } - g, stateCtx, d, err := convertor.FindGame(h.e, req.Msg.GameId, req.Msg.GameRev) + g, stateCtx, d, err := convertor.FindGame(e, msg.GameId, msg.GameRev) if err != nil { return nil, connect.NewError(connect.CodeInternal, err) } @@ -41,7 +44,7 @@ func (h *Handler) Pass(_ context.Context, req *connect.Request[v1.PassRequest]) if !(stateCtx.Current.Labels[`game.state`] == `started` || stateCtx.Current.Labels[`game.state`] == `move`) { return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("state is not move")) } - if g.CurrentMove.PlayerId != req.Msg.PlayerId { + if g.CurrentMove.PlayerId != msg.PlayerId { return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("not player's turn")) } @@ -63,8 +66,8 @@ func (h *Handler) Pass(_ context.Context, req *connect.Request[v1.PassRequest]) return nil, connect.NewError(connect.CodeInternal, err) } - if err := h.e.Do(flowstate.Commit( - flowstate.AttachData(stateCtx, d, `game`), + if err := e.Do(flowstate.Commit( + flowstate.StoreData(stateCtx, `game`), flowstate.Park(stateCtx), )); err != nil { return nil, connect.NewError(connect.CodeInternal, err) @@ -72,9 +75,9 @@ func (h *Handler) Pass(_ context.Context, req *connect.Request[v1.PassRequest]) g.Rev = int32(stateCtx.Current.Rev) - return connect.NewResponse(&v1.PassResponse{ + return flowstate.Noop(), promutil.MarshalResponse(reqStateCtx, &v1.PassResponse{ Game: g, - }), nil + }) } g.State = v1.State_STATE_MOVE @@ -89,8 +92,8 @@ func (h *Handler) Pass(_ context.Context, req *connect.Request[v1.PassRequest]) return nil, connect.NewError(connect.CodeInternal, err) } - if err := h.e.Do(flowstate.Commit( - flowstate.AttachData(stateCtx, d, `game`), + if err := e.Do(flowstate.Commit( + flowstate.StoreData(stateCtx, `game`), flowstate.Park(stateCtx), flowstate.Delay(stateCtx, movetimeoutflow.ID, time.Duration(g.MoveDurationSec)*time.Second), )); err != nil { @@ -99,7 +102,7 @@ func (h *Handler) Pass(_ context.Context, req *connect.Request[v1.PassRequest]) g.Rev = int32(stateCtx.Current.Rev) - return connect.NewResponse(&v1.PassResponse{ + return flowstate.Noop(), promutil.MarshalResponse(reqStateCtx, &v1.PassResponse{ Game: g, - }), nil + }) } diff --git a/internal/promutil/util.go b/internal/promutil/util.go new file mode 100644 index 0000000..c642cac --- /dev/null +++ b/internal/promutil/util.go @@ -0,0 +1,175 @@ +package promutil + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + + "connectrpc.com/connect" + "github.com/VictoriaMetrics/easyproto" + "github.com/makasim/flowstate" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" +) + +func WriteOK(rw http.ResponseWriter, d *flowstate.Data) { + rw.Header().Set("Content-Type", d.Annotations["content-type"]) + rw.WriteHeader(http.StatusOK) + + _, _ = rw.Write(d.Blob) +} + +func WriteError(rw http.ResponseWriter, err error, proto bool) { + var connErr *connect.Error + if errors.As(err, &connErr) { + switch connErr.Code() { + case connect.CodeInvalidArgument: + WriteInvalidArgumentError(rw, connErr.Message(), proto) + case connect.CodeUnknown: + WriteUnknownError(rw, connErr.Message(), proto) + //case connect.CodeInternal: + // WriteInternalError(rw, connErr.Message(), proto) + //case connect.CodeUnimplemented: + // WriteUnimplementedError(rw, connErr.Message(), proto) + case connect.CodeNotFound: + WriteNotFoundError(rw, connErr.Message(), proto) + //case connect.CodeAlreadyExists: + // WriteAlreadyExistsError(rw, connErr.Message(), proto) + //case connect.CodeUnauthenticated: + // WriteUnauthenticatedError(rw, connErr.Message(), proto) + //case connect.CodePermissionDenied: + // WritePermissionDeniedError(rw, connErr.Message(), proto) + default: + WriteUnknownError(rw, connErr.Message(), proto) + } + } else { + WriteUnknownError(rw, err.Error(), proto) + } +} + +func WriteUnknownError(rw http.ResponseWriter, message string, proto bool) { + if proto { + rw.Header().Set("Content-Type", "application/proto") + } else { + rw.Header().Set("Content-Type", "application/json") + } + + rw.WriteHeader(http.StatusInternalServerError) + + if proto { + _, _ = rw.Write(MarshalError("unknown", message)) + } else { + _, _ = rw.Write(MarshalJSONError("unknown", message)) + } +} + +func WriteInvalidArgumentError(rw http.ResponseWriter, message string, proto bool) { + if proto { + rw.Header().Set("Content-Type", "application/proto") + } else { + rw.Header().Set("Content-Type", "application/json") + } + + rw.WriteHeader(http.StatusBadRequest) + + if proto { + _, _ = rw.Write(MarshalError("invalid_argument", message)) + } else { + _, _ = rw.Write(MarshalJSONError("invalid_argument", message)) + } +} + +func WriteNotFoundError(rw http.ResponseWriter, message string, proto bool) { + if proto { + rw.Header().Set("Content-Type", "application/proto") + } else { + rw.Header().Set("Content-Type", "application/json") + } + + rw.WriteHeader(http.StatusNotFound) + + if proto { + _, _ = rw.Write(MarshalError("not_found", message)) + } else { + _, _ = rw.Write(MarshalJSONError("not_found", message)) + } +} + +func MarshalJSONError(code, message string) []byte { + b, _ := json.Marshal(map[string]string{ + "code": code, + "message": message, + }) + return b +} + +func MarshalError(code, message string) []byte { + m := &easyproto.Marshaler{} + mm := m.MessageMarshaler() + + if code != "" { + mm.AppendString(1, code) + } + if message != "" { + mm.AppendString(2, message) + } + + return m.Marshal(nil) +} + +func UnmarshalRequest(stateCtx *flowstate.StateCtx, msg proto.Message) error { + reqData, err := stateCtx.Data("request") + if err != nil { + return fmt.Errorf("failed to get request data: %w", err) + } + + // Unmarshal based on content-type annotation + contentType := reqData.Annotations["content-type"] + switch contentType { + case "application/protobuf", "application/x-protobuf": + if err := proto.Unmarshal(reqData.Blob, msg); err != nil { + return connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("failed to unmarshal protobuf request: %w", err)) + } + case "application/json", "application/protojson": + if err := protojson.Unmarshal(reqData.Blob, msg); err != nil { + return connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("failed to unmarshal protojson request: %w", err)) + } + default: + return connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("unsupported content-type: %s", contentType)) + } + + return nil +} + +func MarshalResponse(stateCtx *flowstate.StateCtx, msg proto.Message) error { + reqData := stateCtx.MustData("request") + + respData := &flowstate.Data{ + Annotations: map[string]string{ + "content-type": reqData.Annotations["content-type"], + }, + } + + contentType := respData.Annotations["content-type"] + switch contentType { + case "application/protobuf", "application/x-protobuf": + b, err := proto.Marshal(msg) + if err != nil { + return connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("failed to unmarshal protobuf request: %w", err)) + } + respData.Blob = b + case "application/json", "application/protojson": + b, err := protojson.Marshal(msg) + if err != nil { + return connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("failed to unmarshal protojson request: %w", err)) + } + respData.Blob = b + default: + return connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("unsupported content-type: %s", contentType)) + } + + stateCtx.Datas["response"] = respData + + return nil +} diff --git a/internal/api/gameservicev1/resignhandler/handler.go b/internal/resignflow/flow.go similarity index 60% rename from internal/api/gameservicev1/resignhandler/handler.go rename to internal/resignflow/flow.go index 79cefdd..f300ad9 100644 --- a/internal/api/gameservicev1/resignhandler/handler.go +++ b/internal/resignflow/flow.go @@ -1,34 +1,37 @@ -package resignhandler +package resignflow import ( - "context" "fmt" "connectrpc.com/connect" "github.com/makasim/flowstate" "github.com/makasim/gogame/internal/api/convertor" + "github.com/makasim/gogame/internal/promutil" v1 "github.com/makasim/gogame/protogen/gogame/v1" ) -type Handler struct { - e flowstate.Engine +var ID flowstate.FlowID = `gogame.resign` + +type Flow struct { } -func New(e flowstate.Engine) *Handler { - return &Handler{ - e: e, - } +func New() (flowstate.FlowID, *Flow) { + return ID, &Flow{} } -func (h *Handler) Resign(_ context.Context, req *connect.Request[v1.ResignRequest]) (*connect.Response[v1.ResignResponse], error) { - if req.Msg.GameId == `` { +func (f *Flow) Execute(reqStateCtx *flowstate.StateCtx, e flowstate.Engine) (flowstate.Command, error) { + msg := &v1.ResignRequest{} + if err := promutil.UnmarshalRequest(reqStateCtx, msg); err != nil { + return nil, err + } + if msg.GameId == `` { return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("game id is required")) } - if req.Msg.PlayerId == `` { + if msg.PlayerId == `` { return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("player id is required")) } - g, stateCtx, d, err := convertor.FindGame(h.e, req.Msg.GameId, 0) + g, stateCtx, d, err := convertor.FindGame(e, msg.GameId, 0) if err != nil { return nil, connect.NewError(connect.CodeInternal, err) } @@ -46,8 +49,8 @@ func (h *Handler) Resign(_ context.Context, req *connect.Request[v1.ResignReques return nil, connect.NewError(connect.CodeInternal, err) } - if err := h.e.Do(flowstate.Commit( - flowstate.AttachData(stateCtx, d, `game`), + if err := e.Do(flowstate.Commit( + flowstate.StoreData(stateCtx, `game`), flowstate.Park(stateCtx), )); err != nil { return nil, connect.NewError(connect.CodeInternal, err) @@ -55,7 +58,7 @@ func (h *Handler) Resign(_ context.Context, req *connect.Request[v1.ResignReques g.Rev = int32(stateCtx.Current.Rev) - return connect.NewResponse(&v1.ResignResponse{ + return flowstate.Noop(), promutil.MarshalResponse(reqStateCtx, &v1.ResignResponse{ Game: g, - }), nil + }) } diff --git a/internal/staleflow/flow.go b/internal/staleflow/flow.go index dba8a79..eb233d1 100644 --- a/internal/staleflow/flow.go +++ b/internal/staleflow/flow.go @@ -16,11 +16,11 @@ func New() (flowstate.FlowID, *Flow) { } func (f *Flow) Execute(stateCtx *flowstate.StateCtx, e flowstate.Engine) (flowstate.Command, error) { - d := &flowstate.Data{} - if err := e.Do(flowstate.GetData(stateCtx, d, `game`)); err != nil { + if err := e.Do(flowstate.GetData(stateCtx, `game`)); err != nil { return nil, err } + d := stateCtx.MustData(`game`) g, err := convertor.DataToGame(d) if err != nil { return nil, err @@ -37,7 +37,7 @@ func (f *Flow) Execute(stateCtx *flowstate.StateCtx, e flowstate.Engine) (flowst } return flowstate.Commit( - flowstate.AttachData(stateCtx, d, `game`), + flowstate.StoreData(stateCtx, `game`), flowstate.Park(stateCtx), ), nil } diff --git a/internal/undoflow/flow.go b/internal/undoflow/flow.go new file mode 100644 index 0000000..f2088b0 --- /dev/null +++ b/internal/undoflow/flow.go @@ -0,0 +1,172 @@ +package undoflow + +import ( + "fmt" + "time" + + "connectrpc.com/connect" + "github.com/makasim/flowstate" + "github.com/makasim/gogame/internal/api/convertor" + "github.com/makasim/gogame/internal/movetimeoutflow" + "github.com/makasim/gogame/internal/promutil" + v1 "github.com/makasim/gogame/protogen/gogame/v1" +) + +var ID flowstate.FlowID = `gogame.undo` + +type Flow struct { +} + +func New() (flowstate.FlowID, *Flow) { + return ID, &Flow{} +} + +func (f *Flow) Execute(reqStateCtx *flowstate.StateCtx, e flowstate.Engine) (flowstate.Command, error) { + msg := &v1.UndoRequest{} + if err := promutil.UnmarshalRequest(reqStateCtx, msg); err != nil { + return nil, err + } + if msg.GameId == `` { + return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("game id is required")) + } + if msg.GameRev <= 0 { + return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("game rev is required")) + } + + g, stateCtx, d, err := convertor.FindGame(e, msg.GameId, msg.GameRev) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + if len(g.PreviousMoves) == 0 { + return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("no moves to undo")) + } + + m := g.PreviousMoves[len(g.PreviousMoves)-1] + + switch { + case msg.GetRequest() != nil: + undoReq := msg.GetRequest() + + if undoReq.PlayerId != m.PlayerId { + return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("cannot undo other player's move")) + } + + u := &v1.Undo{ + GameId: g.Id, + GameRev: g.Rev, + PlayerId: m.PlayerId, + Move: int32(len(g.PreviousMoves)), + } + + undoD := &flowstate.Data{} + if err := convertor.UndoToData(u, undoD); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + + undoStateCtx := &flowstate.StateCtx{ + Current: flowstate.State{ + ID: flowstate.StateID(fmt.Sprintf(`undo-%s-%d`, g.Id, g.Rev)), + Labels: map[string]string{ + `undo.game.id`: g.Id, + }, + Annotations: map[string]string{ + `game.id`: g.Id, + `game.rev`: fmt.Sprintf(`%d`, g.Rev), + }, + }, + Datas: map[string]*flowstate.Data{ + "undo": undoD, + }, + } + + if err := e.Do(flowstate.Commit( + flowstate.StoreData(undoStateCtx, `undo`), + flowstate.Park(undoStateCtx), + )); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + + return flowstate.Noop(), promutil.MarshalResponse(reqStateCtx, &v1.UndoResponse{ + Game: g, + Undo: u, + }) + case msg.GetDecision() != nil: + undoDecision := msg.GetDecision() + + if undoDecision.PlayerId == m.PlayerId { + return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("cannot decide on own undo")) + } + + undoStateCtx := &flowstate.StateCtx{} + if err := e.Do( + flowstate.GetStateByID(undoStateCtx, flowstate.StateID(fmt.Sprintf(`undo-%s-%d`, g.Id, g.Rev)), 0), + flowstate.GetData(undoStateCtx, `undo`), + ); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + + undoD := undoStateCtx.MustData(`undo`) + undo, err := convertor.DataToUndo(undoD) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + + if undo.Decided { + return flowstate.Noop(), promutil.MarshalResponse(reqStateCtx, &v1.UndoResponse{ + Undo: undo, + }) + } + + undo.Accepted = undoDecision.Accepted + undo.Decided = true + if err := convertor.UndoToData(undo, undoD); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + + if !undo.Accepted { + if err := e.Do(flowstate.Commit( + flowstate.StoreData(undoStateCtx, `undo`), + flowstate.Park(undoStateCtx), + )); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + + return flowstate.Noop(), promutil.MarshalResponse(reqStateCtx, &v1.UndoResponse{ + Undo: undo, + }) + } + + m.Undone = true + g.CurrentMove = &v1.Move{ + PlayerId: m.PlayerId, + Color: m.Color, + EndAt: time.Now().Add(time.Duration(g.MoveDurationSec) * time.Second).Unix(), + } + b, err := convertor.GameToBoard(g) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + g.Board = convertor.FromClamBoard(b) + + if err := convertor.GameToData(g, d); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + + if err := e.Do(flowstate.Commit( + flowstate.StoreData(undoStateCtx, `undo`), + flowstate.StoreData(stateCtx, `game`), + flowstate.Park(undoStateCtx), + flowstate.Park(stateCtx), + flowstate.Delay(stateCtx, movetimeoutflow.ID, time.Duration(g.MoveDurationSec)*time.Second), + )); err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + + return flowstate.Noop(), promutil.MarshalResponse(reqStateCtx, &v1.UndoResponse{ + Game: g, + Undo: undo, + }) + default: + return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("invalid request")) + } +}