diff --git a/ai.go b/ai.go index 78a7b00..0165070 100644 --- a/ai.go +++ b/ai.go @@ -8,6 +8,7 @@ import ( "time" "github.com/fzipp/astar" + "github.com/golang-cz/snake/proto" ) @@ -15,23 +16,41 @@ func (s *Server) createFood() { s.mu.Lock() defer s.mu.Unlock() - s.state.Items[s.lastItemId] = &proto.Item{ + bite := proto.ItemType_bite + item := &proto.Item{ Id: s.lastItemId, Color: "red", - Body: &proto.Square{ + Coordinate: &proto.Coordinate{ X: uint(rand.Intn(int(s.state.Width))), Y: uint(rand.Intn(int(s.state.Height))), }, + Type: &bite, } + + s.state.Items[s.lastItemId] = item s.lastItemId++ + + u := &proto.Update{ + Diffs: []*proto.Diff{ + { + X: item.Coordinate.X, + Y: item.Coordinate.Y, + Color: item.Color, + Add: true, + }, + }, + } + + s.sendUpdate(u) } func (s *Server) generateFood() { - s.createFood() - s.createFood() - s.createFood() + for i := 0; i < NumOfStartingFood; i++ { + s.createFood() + } + for { - <-time.After(2 * time.Second) + <-time.After(FoodGenerateInterval) s.createFood() } } @@ -43,7 +62,7 @@ func (s *Server) currentGrid() grid { } for _, item := range s.state.Items { - grid.put(image.Point{X: int(item.Body.X), Y: int(item.Body.Y)}, '*') + grid.put(image.Point{X: int(item.Coordinate.X), Y: int(item.Coordinate.Y)}, '*') } for _, snake := range s.state.Snakes { @@ -58,7 +77,7 @@ func (s *Server) currentGrid() grid { func (s *Server) generateSnakeTurns(grid grid) { // Turn "AI" snakes to the closes food using A* algorithm. for _, snake := range s.state.Snakes { - if snake.Name != "AI" { + if !strings.Contains(snake.Name, "AI") { continue } @@ -75,7 +94,7 @@ func (s *Server) generateSnakeTurns(grid grid) { shortestPathLen := math.MaxInt for _, item := range s.state.Items { - food := squareToPoint(item.Body) + food := squareToPoint(item.Coordinate) path := astar.FindPath[image.Point](grid, snakeHead, food, distance, distance) if len(path) > 1 && len(path) < shortestPathLen { @@ -97,20 +116,20 @@ func (s *Server) generateSnakeTurns(grid grid) { switch { case snakeHead.X < nextSquare.X: - if turnSnake(snake, &right, 0) == proto.ErrTurnAbout { - turnSnake(snake, &up, 0) + if turnSnake(snake, &Right, 0) == proto.ErrTurnAbout { + turnSnake(snake, &Up, 0) } case snakeHead.X > nextSquare.X: - if turnSnake(snake, &left, 0) == proto.ErrTurnAbout { - turnSnake(snake, &down, 0) + if turnSnake(snake, &Left, 0) == proto.ErrTurnAbout { + turnSnake(snake, &Down, 0) } case snakeHead.Y < nextSquare.Y: - if turnSnake(snake, &down, 0) == proto.ErrTurnAbout { - turnSnake(snake, &left, 0) + if turnSnake(snake, &Down, 0) == proto.ErrTurnAbout { + turnSnake(snake, &Left, 0) } case snakeHead.Y > nextSquare.Y: - if turnSnake(snake, &up, 0) == proto.ErrTurnAbout { - turnSnake(snake, &right, 0) + if turnSnake(snake, &Up, 0) == proto.ErrTurnAbout { + turnSnake(snake, &Right, 0) } } } diff --git a/game.go b/game.go index 104430d..521c0ae 100644 --- a/game.go +++ b/game.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "image" + "strings" "time" "github.com/golang-cz/snake/proto" @@ -11,25 +12,30 @@ import ( func (s *Server) Run(ctx context.Context) error { go s.generateFood() + go s.generateAISnakes() - for i := 0; i < 3; i++ { - s.createSnake("AI") + ticker := time.NewTicker(GameTickTime) + defer ticker.Stop() + + for range ticker.C { + // if err := s.gameTick(); err != nil { + // return fmt.Errorf("advancing the game: %w", err) + // } + + s.gameTick() } - ticker := time.NewTicker(75 * time.Millisecond) - defer ticker.Stop() + return nil +} - for { - select { - case <-ticker.C: - if err := s.gameTick(); err != nil { - return fmt.Errorf("advancing the game: %w", err) - } - } +func (s *Server) generateAISnakes() { + for i := 0; i < NumOfAISnakes; i++ { + time.Sleep(time.Second) + go s.createSnake(fmt.Sprintf("AI%v", i)) } } -func (s *Server) gameTick() error { +func (s *Server) gameTick() { s.mu.Lock() defer s.mu.Unlock() @@ -38,9 +44,13 @@ func (s *Server) gameTick() error { s.generateSnakeTurns(grid) + u := &proto.Update{ + Diffs: []*proto.Diff{}, + } + // Move snakes. for _, snake := range s.state.Snakes { - next := &proto.Square{} + next := &proto.Coordinate{} if len(snake.NextDirections) > 0 { snake.Direction = snake.NextDirections[0] @@ -69,66 +79,117 @@ func (s *Server) gameTick() error { // Move snake's head. grid.put(nextPoint, snakeRune(snake.Id)) - snake.Body = append([]*proto.Square{next}, snake.Body...) + snake.Body = append([]*proto.Coordinate{next}, snake.Body...) // Remove food. for _, item := range s.state.Items { - if item.Body.X == next.X && item.Body.Y == next.Y { + if item.Coordinate.X == next.X && item.Coordinate.Y == next.Y { delete(s.state.Items, item.Id) } } + diff := &proto.Diff{ + X: next.X, + Y: next.Y, + Color: snake.Color, + Add: true, + } + + u.Diffs = append(u.Diffs, diff) + + snake.Length = len(snake.Body) + case ' ', '.', snakeRune(snake.Id): // Move snake's head & remove tail. - tailPoint := squareToPoint(snake.Body[len(snake.Body)-1]) + tail := snake.Body[len(snake.Body)-1] + tailPoint := squareToPoint(tail) grid.put(tailPoint, ' ') grid.put(nextPoint, snakeRune(snake.Id)) - snake.Body = append([]*proto.Square{next}, snake.Body[:len(snake.Body)-1]...) + headDiff := &proto.Diff{ + X: next.X, + Y: next.Y, + Color: snake.Color, + Add: true, + } + + tailDiff := &proto.Diff{ + X: tail.X, + Y: tail.Y, + Add: false, + } + + u.Diffs = append(u.Diffs, headDiff) + u.Diffs = append(u.Diffs, tailDiff) + + snake.Body = append([]*proto.Coordinate{next}, snake.Body[:len(snake.Body)-1]...) default: // Crashed into another snake. + name := snake.Name delete(s.state.Snakes, snake.Id) - // Create food from snake's body. - for i, square := range snake.Body { - if i%2 == 0 { - s.state.Items[s.lastItemId] = &proto.Item{ - Id: s.lastItemId, - Color: "red", - Body: square, + bite := proto.ItemType_bite + for i, bodyPart := range snake.Body { + diff := &proto.Diff{ + X: bodyPart.X, + Y: bodyPart.Y, + } + + // Create food from snake's body. + if i%FoodFromDeadSnake == 0 { + item := &proto.Item{ + Id: s.lastItemId, + Color: "red", + Coordinate: bodyPart, + Type: &bite, } + + s.state.Items[s.lastItemId] = item s.lastItemId++ + + diff.Color = item.Color + diff.Add = true } + + u.Diffs = append(u.Diffs, diff) } // Reborn AI. - if snake.Name == "AI" { + if strings.Contains(snake.Name, "AI") { go func() { - <-time.After(10 * time.Second) - s.createSnake("AI") + <-time.After(AISnakeRespawnTime) + s.createSnake(name) }() } + } + } + // Generate bite, if snakes ate all bites + bites := 0 + for _, item := range s.state.Items { + if item.Type != nil && *item.Type == proto.ItemType_bite { + bites++ } } - return s.sendState(s.state) + if bites == 0 { + go s.createFood() + } + + s.sendUpdate(u) } -// TODO: We send the whole state on each update. Optimize to send events (diffs) only. -func (s *Server) sendState(state *proto.State) error { +func (s *Server) sendUpdate(state *proto.Update) { for _, sub := range s.subs { sub := sub go func() { sub <- state }() } - return nil } -var runes = []rune{'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z'} - +// Get letter from A to Z func snakeRune(snakeId uint64) rune { - return runes[int(snakeId)%len(runes)] + return rune((snakeId % 26) + 65) } diff --git a/grid.go b/grid.go index 01db976..e596a28 100644 --- a/grid.go +++ b/grid.go @@ -3,6 +3,9 @@ package main import ( "image" "math" + "os" + "os/exec" + "runtime" "strings" "github.com/golang-cz/snake/proto" @@ -11,6 +14,7 @@ import ( type grid []string func (g grid) String() string { + clearTerminal() var b strings.Builder b.WriteByte('\n') b.WriteString(strings.Repeat("▮", len(g)+2)) @@ -65,9 +69,22 @@ func distance(p, q image.Point) float64 { return math.Sqrt(float64(d.X*d.X + d.Y*d.Y)) } -func squareToPoint(s *proto.Square) image.Point { +func squareToPoint(s *proto.Coordinate) image.Point { return image.Point{ X: int(s.X), Y: int(s.Y), } } + +func clearTerminal() { + var cmd *exec.Cmd + + if runtime.GOOS == "windows" { + cmd = exec.Command("cmd", "/c", "cls") + } else { + cmd = exec.Command("clear") + } + + cmd.Stdout = os.Stdout + cmd.Run() +} diff --git a/main.go b/main.go index 7f99201..54c1a21 100644 --- a/main.go +++ b/main.go @@ -6,22 +6,32 @@ import ( "log" "log/slog" "net/http" + "time" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/cors" + "github.com/golang-cz/snake/proto" ) +const ( + PORT = 5252 + NumOfAISnakes = 3 + FoodFromDeadSnake = 4 + GameTickTime = 100 * time.Millisecond + NumOfStartingFood = 1 + FoodGenerateInterval = 5 * time.Second + AISnakeRespawnTime = 5 * time.Second +) + func main() { - port := 5252 - slog.Info(fmt.Sprintf("serving at http://localhost:%v", port)) + slog.Info(fmt.Sprintf("serving at http://localhost:%v", PORT)) rpc := NewSnakeServer() go rpc.Run(context.TODO()) - err := http.ListenAndServe(fmt.Sprintf("0.0.0.0:%v", port), rpc.Router()) - if err != nil { + if err := http.ListenAndServe(fmt.Sprintf("0.0.0.0:%v", PORT), rpc.Router()); err != nil { log.Fatal(err) } } @@ -29,8 +39,8 @@ func main() { func (s *Server) Router() http.Handler { r := chi.NewRouter() r.Use(middleware.RequestID) - //r.Use(requestDebugger) - //r.Use(middleware.Recoverer) + // r.Use(requestDebugger) + // r.Use(middleware.Recoverer) cors := cors.New(cors.Options{ // AllowedOrigins: []string{"https://foo.com"}, // Use this to allow specific origin hosts @@ -56,16 +66,17 @@ func (s *Server) Router() http.Handler { func requestDebugger(next http.Handler) http.Handler { fn := func(w http.ResponseWriter, r *http.Request) { - slog.Info(fmt.Sprintf("req started"), + slog.Info("req started", slog.String("url", fmt.Sprintf("%v %v", r.Method, r.URL.String()))) defer func() { - slog.Info(fmt.Sprintf("req finished"), + slog.Info("req finished", slog.String("url", fmt.Sprintf("%v %v", r.Method, r.URL.String())), ) }() next.ServeHTTP(w, r) } + return http.HandlerFunc(fn) } diff --git a/mechanics.go b/mechanics.go index 59c0d01..a54bc2c 100644 --- a/mechanics.go +++ b/mechanics.go @@ -7,23 +7,31 @@ import ( ) func (s *Server) createSnake(username string) (uint64, error) { - s.lastSnakeId++ - randOffset := uint(rand.Intn(10) - rand.Intn(10)) snakeId := s.lastSnakeId - s.state.Snakes[snakeId] = &proto.Snake{ + snake := &proto.Snake{ Id: snakeId, Name: username, Color: randColor(), - Body: []*proto.Square{ + Body: []*proto.Coordinate{ {X: 36, Y: 35 + randOffset}, {X: 35, Y: 35 + randOffset}, {X: 34, Y: 35 + randOffset}, }, - Direction: &right, + Direction: &Right, } + s.state.Snakes[snakeId] = snake + + u := &proto.Update{ + Diffs: generateDiffFromBody(snake), + } + + s.sendUpdate(u) + + s.lastSnakeId++ + return snakeId, nil } @@ -58,3 +66,18 @@ func turnSnake(snake *proto.Snake, direction *proto.Direction, buf int) error { return nil } + +func generateDiffFromBody(s *proto.Snake) (diffs []*proto.Diff) { + for _, bodyPart := range s.Body { + diff := &proto.Diff{ + X: bodyPart.X, + Y: bodyPart.Y, + Color: s.Color, + Add: true, + } + + diffs = append(diffs, diff) + } + + return diffs +} diff --git a/proto/snake.gen.go b/proto/snake.gen.go index cc3d0c7..154f219 100644 --- a/proto/snake.gen.go +++ b/proto/snake.gen.go @@ -1,4 +1,4 @@ -// snake v1.0.0 4b39bab532cde9335759576c8b44254075bd954b +// snake v1.0.0 508dedc89337da00dbd6622fe89442e076f73327 // -- // Code generated by webrpc-gen@v0.14.0-dev with ../../gen-golang generator. DO NOT EDIT. // @@ -32,7 +32,7 @@ func WebRPCSchemaVersion() string { // Schema hash generated from your RIDL schema func WebRPCSchemaHash() string { - return "4b39bab532cde9335759576c8b44254075bd954b" + return "508dedc89337da00dbd6622fe89442e076f73327" } @@ -127,6 +127,18 @@ func (x *ItemType) Is(values ...ItemType) bool { return false } +type Update struct { + Diffs []*Diff `json:"diffs"` + State *State `json:"state"` +} + +type Diff struct { + X uint `json:"x"` + Y uint `json:"y"` + Color string `json:"color"` + Add bool `json:"add"` +} + type State struct { Width uint `json:"width"` Height uint `json:"height"` @@ -138,7 +150,7 @@ type Snake struct { Id uint64 `json:"id"` Name string `json:"name"` Color string `json:"color"` - Body []*Square `json:"body"` + Body []*Coordinate `json:"body"` Direction *Direction `json:"direction"` NextDirections []*Direction `json:"nextDirections"` Length int `json:"length"` @@ -150,13 +162,13 @@ type Item struct { Id uint64 `json:"id"` Color string `json:"color"` Type *ItemType `json:"type"` - Body *Square `json:"body"` + Coordinate *Coordinate `json:"coordinate"` } type Event struct { } -type Square struct { +type Coordinate struct { X uint `json:"x"` Y uint `json:"y"` } @@ -179,7 +191,7 @@ type SnakeGame interface { TurnSnake(ctx context.Context, snakeId uint64, direction *Direction) error } type JoinGameStreamWriter interface { - Write(state *State, event *Event) error + Write(update *Update, event *Event) error } @@ -188,12 +200,12 @@ type joinGameStreamWriter struct { streamWriter } -func (w *joinGameStreamWriter) Write(state *State, event *Event) error { +func (w *joinGameStreamWriter) Write(update *Update, event *Event) error { out := struct { - Ret0 *State `json:"state"` + Ret0 *Update `json:"update"` Ret1 *Event `json:"event"` }{ - Ret0: state, + Ret0: update, Ret1: event, } @@ -254,7 +266,7 @@ type SnakeGameClient interface { TurnSnake(ctx context.Context, snakeId uint64, direction *Direction) error } type JoinGameStreamReader interface { - Read() (state *State, event *Event, err error) + Read() (update *Update, event *Event, err error) } @@ -296,7 +308,7 @@ func (s *snakeGameServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { var handler func(ctx context.Context, w http.ResponseWriter, r *http.Request) switch r.URL.Path { case "/rpc/SnakeGame/JoinGame": - handler = s.serveJoinGameJSONStream + handler = s.serveJoinGameNDJSON case "/rpc/SnakeGame/CreateSnake": handler = s.serveCreateSnakeJSON case "/rpc/SnakeGame/TurnSnake": @@ -329,7 +341,7 @@ func (s *snakeGameServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } -func (s *snakeGameServer) serveJoinGameJSONStream(ctx context.Context, w http.ResponseWriter, r *http.Request) { +func (s *snakeGameServer) serveJoinGameNDJSON(ctx context.Context, w http.ResponseWriter, r *http.Request) { ctx = context.WithValue(ctx, MethodNameCtxKey, "JoinGame") @@ -523,9 +535,9 @@ type joinGameStreamReader struct { streamReader } -func (r *joinGameStreamReader) Read() (*State, *Event, error) { +func (r *joinGameStreamReader) Read() (*Update, *Event, error) { out := struct { - Ret0 *State `json:"state"` + Ret0 *Update `json:"update"` Ret1 *Event `json:"event"` WebRPCError *WebRPCError `json:"webrpcError"` }{} diff --git a/proto/snake.ridl b/proto/snake.ridl index d6fe45a..70784b1 100644 --- a/proto/snake.ridl +++ b/proto/snake.ridl @@ -4,10 +4,20 @@ name = snake version = v1.0.0 service SnakeGame - - JoinGame() => stream (state: State, event: Event) + - JoinGame() => stream (update: Update, event: Event) - CreateSnake(username: string) => (snakeId: uint64) - TurnSnake(snakeId: uint64, direction: Direction) +struct Update + - diffs: []Diff + - state: State + +struct Diff + - x: uint + - y: uint + - color: string + - add: bool + struct State - width: uint - height: uint @@ -18,7 +28,7 @@ struct Snake - id: uint64 - name: string - color: string - - body: []Square + - body: []Coordinate - direction: Direction - nextDirections: []Direction - length: int @@ -29,11 +39,11 @@ struct Item - id: uint64 - color: string - type: ItemType - - body: Square + - coordinate: Coordinate struct Event -struct Square +struct Coordinate - x: uint - y: uint diff --git a/rand.go b/rand.go index 71d8da2..d7ea58d 100644 --- a/rand.go +++ b/rand.go @@ -12,6 +12,6 @@ func randColor() string { } func randDirection() *proto.Direction { - directions := []*proto.Direction{&left, &up, &right, &down} + directions := []*proto.Direction{&Left, &Up, &Right, &Down} return directions[rand.Intn(len(directions))] } diff --git a/rpc.go b/rpc.go index 54cee3c..f2a6a1c 100644 --- a/rpc.go +++ b/rpc.go @@ -8,13 +8,13 @@ import ( ) func (s *Server) JoinGame(ctx context.Context, stream proto.JoinGameStreamWriter) error { - events := make(chan *proto.State, 10) + events := make(chan *proto.Update, 10) - state, subscriptionId := s.subscribe(events) + update, subscriptionId := s.subscribe(events) defer s.unsubscribe(subscriptionId) // Send initial state. - if err := stream.Write(state, nil); err != nil { + if err := stream.Write(update, nil); err != nil { return err } @@ -29,8 +29,8 @@ func (s *Server) JoinGame(ctx context.Context, stream proto.JoinGameStreamWriter return proto.ErrWebrpcInternalError } - case state := <-events: - if err := stream.Write(state, nil); err != nil { + case update := <-events: + if err := stream.Write(update, nil); err != nil { return err } } diff --git a/server.go b/server.go index ce56e2e..ea4faed 100644 --- a/server.go +++ b/server.go @@ -10,7 +10,7 @@ type Server struct { mu sync.Mutex state *proto.State events chan *proto.Event - subs map[uint64]chan *proto.State + subs map[uint64]chan *proto.Update lastSnakeId uint64 lastItemId uint64 @@ -18,10 +18,10 @@ type Server struct { } var ( - down = proto.Direction_down - up = proto.Direction_up - left = proto.Direction_left - right = proto.Direction_right + Left = proto.Direction_left + Right = proto.Direction_right + Up = proto.Direction_up + Down = proto.Direction_down ) func NewSnakeServer() *Server { @@ -33,6 +33,6 @@ func NewSnakeServer() *Server { Items: map[uint64]*proto.Item{}, }, events: make(chan *proto.Event, 100000), - subs: map[uint64]chan *proto.State{}, + subs: map[uint64]chan *proto.Update{}, } } diff --git a/subscribe.go b/subscribe.go index 74d93a2..67df353 100644 --- a/subscribe.go +++ b/subscribe.go @@ -2,7 +2,7 @@ package main import "github.com/golang-cz/snake/proto" -func (s *Server) subscribe(c chan *proto.State) (*proto.State, uint64) { +func (s *Server) subscribe(c chan *proto.Update) (*proto.Update, uint64) { s.mu.Lock() defer s.mu.Unlock() @@ -10,7 +10,11 @@ func (s *Server) subscribe(c chan *proto.State) (*proto.State, uint64) { s.subs[id] = c s.lastSubId++ - return s.state, id + u := &proto.Update{ + State: s.state, + } + + return u, id } func (s *Server) unsubscribe(subscriptionId uint64) { diff --git a/webapp/src/lib/rpc.gen.ts b/webapp/src/lib/rpc.gen.ts index b500b81..ab8fcd9 100644 --- a/webapp/src/lib/rpc.gen.ts +++ b/webapp/src/lib/rpc.gen.ts @@ -1,583 +1,607 @@ /* eslint-disable */ -// snake v1.0.0 4b39bab532cde9335759576c8b44254075bd954b +// snake v1.0.0 508dedc89337da00dbd6622fe89442e076f73327 // -- // Code generated by webrpc-gen@v0.14.0-dev with ../../gen-typescript generator. DO NOT EDIT. // // webrpc-gen -schema=proto/snake.ridl -target=../../gen-typescript -client -out=webapp/src/lib/rpc.gen.ts // WebRPC description and code-gen version -export const WebRPCVersion = 'v1'; +export const WebRPCVersion = "v1" // Schema version of your RIDL schema -export const WebRPCSchemaVersion = 'v1.0.0'; +export const WebRPCSchemaVersion = "v1.0.0" // Schema hash generated from your RIDL schema -export const WebRPCSchemaHash = '4b39bab532cde9335759576c8b44254075bd954b'; +export const WebRPCSchemaHash = "508dedc89337da00dbd6622fe89442e076f73327" // // Types // + export enum Direction { - left = 'left', - right = 'right', - up = 'up', - down = 'down' + left = 'left', + right = 'right', + up = 'up', + down = 'down' } export enum ItemType { - bite = 'bite' + bite = 'bite' +} + +export interface Update { + diffs: Array + state: State +} + +export interface Diff { + x: number + y: number + color: string + add: boolean } export interface State { - width: number; - height: number; - snakes: { [key: number]: Snake }; - items: { [key: number]: Item }; + width: number + height: number + snakes: {[key: number]: Snake} + items: {[key: number]: Item} } export interface Snake { - id: number; - name: string; - color: string; - body: Array; - direction: Direction; - nextDirections: Array; - length: number; - bornAt: string; - diedAt: string; + id: number + name: string + color: string + body: Array + direction: Direction + nextDirections: Array + length: number + bornAt: string + diedAt: string } export interface Item { - id: number; - color: string; - type: ItemType; - body: Square; + id: number + color: string + type: ItemType + coordinate: Coordinate } -export interface Event {} +export interface Event { +} -export interface Square { - x: number; - y: number; +export interface Coordinate { + x: number + y: number } export interface SnakeGame { - joinGame(options: WebrpcStreamOptions): Promise; - createSnake(args: CreateSnakeArgs, options?: WebrpcOptions): Promise; - turnSnake(args: TurnSnakeArgs, options?: WebrpcOptions): Promise; + joinGame(options: WebrpcStreamOptions): Promise + createSnake(args: CreateSnakeArgs, headers?: object, signal?: AbortSignal): Promise + turnSnake(args: TurnSnakeArgs, headers?: object, signal?: AbortSignal): Promise } -export interface JoinGameArgs {} +export interface JoinGameArgs { +} export interface JoinGameReturn { - state: State; - event: Event; + update: Update + event: Event } export interface CreateSnakeArgs { - username: string; + username: string } export interface CreateSnakeReturn { - snakeId: number; + snakeId: number } export interface TurnSnakeArgs { - snakeId: number; - direction: Direction; + snakeId: number + direction: Direction +} + +export interface TurnSnakeReturn { } -export interface TurnSnakeReturn {} + // // Client // export class SnakeGame implements SnakeGame { - protected hostname: string; - protected fetch: Fetch; - protected path = '/rpc/SnakeGame/'; - - constructor(hostname: string, fetch: Fetch) { - this.hostname = hostname; - this.fetch = (input: RequestInfo, init?: RequestInit) => fetch(input, init); - } - - private url(name: string): string { - return this.hostname + this.path + name; - } - - joinGame = (options: WebrpcStreamOptions): Promise => { - return this.fetch(this.url('JoinGame'), createHTTPRequest({}, options)).then( - async (res) => { - await sseResponse(res, options); - }, - (error) => { - options.onError(error); - } - ); - }; - - createSnake = (args: CreateSnakeArgs, options?: WebrpcOptions): Promise => { - return this.fetch(this.url('CreateSnake'), createHTTPRequest(args, options)).then( - (res) => { - return buildResponse(res).then((_data) => { - return { - snakeId: _data.snakeId - }; - }); - }, - (error) => { - throw WebrpcRequestFailedError.new({ cause: `fetch(): ${error.message || ''}` }); - } - ); - }; - - turnSnake = (args: TurnSnakeArgs, options?: WebrpcOptions): Promise => { - return this.fetch(this.url('TurnSnake'), createHTTPRequest(args, options)).then( - (res) => { - return buildResponse(res).then((_data) => { - return {}; - }); - }, - (error) => { - throw WebrpcRequestFailedError.new({ cause: `fetch(): ${error.message || ''}` }); - } - ); - }; + protected hostname: string + protected fetch: Fetch + protected path = '/rpc/SnakeGame/' + + constructor(hostname: string, fetch: Fetch) { + this.hostname = hostname + this.fetch = (input: RequestInfo, init?: RequestInit) => fetch(input, init) + } + + private url(name: string): string { + return this.hostname + this.path + name + } + + joinGame = (options: WebrpcStreamOptions): Promise => { + const _fetch = () => this.fetch(this.url('JoinGame'),createHTTPRequest({}, options.headers, options.signal) + ).then(async (res) => { + await sseResponse(res, options, _fetch); + }, (error) => { + options.onError(error, _fetch); + }); + return _fetch(); + } + + createSnake = (args: CreateSnakeArgs, headers?: object, signal?: AbortSignal): Promise => { + return this.fetch( + this.url('CreateSnake'), createHTTPRequest(args, headers, signal)).then((res) => { + return buildResponse(res).then(_data => { + return { + snakeId: (_data.snakeId), + } + })}, (error) => {throw WebrpcRequestFailedError.new({ cause: `fetch(): ${error.message || ''}` })} + ) + } + + + turnSnake = (args: TurnSnakeArgs, headers?: object, signal?: AbortSignal): Promise => { + return this.fetch( + this.url('TurnSnake'), createHTTPRequest(args, headers, signal)).then((res) => { + return buildResponse(res).then(_data => { + return {} + })}, (error) => {throw WebrpcRequestFailedError.new({ cause: `fetch(): ${error.message || ''}` })} + ) + } + + } - + const sseResponse = async ( - res: Response, - options: WebrpcStreamOptions, - retryFetch: () => Promise + res: Response, + options: WebrpcStreamOptions, + retryFetch: () => Promise ) => { - const { onMessage, onOpen, onClose, onError } = options; - - if (!res.ok) { - try { - await buildResponse(res); - } catch (error) { - onError(error as WebrpcError, retryFetch); - } - return; - } - - if (!res.body) { - onError( - WebrpcBadResponseError.new({ - status: res.status, - cause: 'Invalid response, missing body' - }) - ); - return; - } - - onOpen && onOpen(); - - const reader = res.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - let lastReadTime = Date.now(); - const timeout = (10 + 1) * 1000; - let intervalId: any; - - try { - intervalId = setInterval(() => { - if (Date.now() - lastReadTime > timeout) { - throw WebrpcStreamLostError.new({ cause: 'Stream timed out' }); - } - }, timeout); - - while (true) { - let value; - let done; - try { - ({ value, done } = await reader.read()); - lastReadTime = Date.now(); - buffer += decoder.decode(value, { stream: true }); - } catch (error) { - let message = ''; - if (error instanceof Error) { - message = error.message; - } - - if (error instanceof DOMException && error.name === 'AbortError') { - onError( - WebrpcRequestFailedError.new({ - message: 'AbortError', - cause: `AbortError: ${message}` - }), - retryFetch - ); - } else { - onError( - WebrpcStreamLostError.new({ - cause: `reader.read(): ${message}` - }), - retryFetch - ); - } - return; - } - - let lines = buffer.split('\n'); - for (let i = 0; i < lines.length - 1; i++) { - if (lines[i].length == 0) { - continue; - } - try { - let data = JSON.parse(lines[i]); - if (data.hasOwnProperty('webrpcError')) { - const error = data.webrpcError; - const code: number = typeof error.code === 'number' ? error.code : 0; - onError((webrpcErrorByCode[code] || WebrpcError).new(error)); - } else { - onMessage(data); - } - } catch (error) { - let message = ''; - if (error instanceof Error) { - message = error.message; - } - onError( - WebrpcBadResponseError.new({ - status: res.status, - cause: `JSON.parse(): ${message}` - }), - retryFetch - ); - } - } - - if (!done) { - buffer = lines[lines.length - 1]; - continue; - } - - onClose && onClose(); - return; - } - } catch (error) { - // WebrpcStreamLostError is thrown when the stream is lost - // @ts-ignore - onError(error, retryFetch); - } finally { - clearInterval(intervalId); - } + const {onMessage, onOpen, onClose, onError} = options; + + if (!res.ok) { + try { + await buildResponse(res); + } catch (error) { + // @ts-ignore + onError(error, retryFetch); + } + return; + } + + if (!res.body) { + onError( + WebrpcBadResponseError.new({ + status: res.status, + cause: "Invalid response, missing body", + }), + retryFetch + ); + return; + } + + onOpen && onOpen(); + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let lastReadTime = Date.now(); + const timeout = (10 + 1) * 1000; + let intervalId: any; + + try { + intervalId = setInterval(() => { + if (Date.now() - lastReadTime > timeout) { + throw WebrpcStreamLostError.new({cause: "Stream timed out"}); + } + }, timeout); + + while (true) { + let value; + let done; + try { + ({value, done} = await reader.read()); + lastReadTime = Date.now(); + buffer += decoder.decode(value, {stream: true}); + } catch (error) { + let message = ""; + if (error instanceof Error) { + message = error.message; + } + + if (error instanceof DOMException && error.name === "AbortError") { + onError( + WebrpcRequestFailedError.new({ + message: "AbortError", + cause: `AbortError: ${message}`, + }), + () => { + throw new Error("Abort signal cannot be used to reconnect"); + } + ); + } else { + onError( + WebrpcStreamLostError.new({ + cause: `reader.read(): ${message}`, + }), + retryFetch + ); + } + return; + } + + let lines = buffer.split("\n"); + for (let i = 0; i < lines.length - 1; i++) { + if (lines[i].length == 0) { + continue; + } + let data: any; + try { + data = JSON.parse(lines[i]); + if (data.hasOwnProperty("webrpcError")) { + const error = data.webrpcError; + const code: number = + typeof error.code === "number" ? error.code : 0; + onError( + (webrpcErrorByCode[code] || WebrpcError).new(error), + retryFetch + ); + return; + } + } catch (error) { + if ( + error instanceof Error && + error.message === "Abort signal cannot be used to reconnect" + ) { + throw error; + } + onError( + WebrpcBadResponseError.new({ + status: res.status, + // @ts-ignore + cause: `JSON.parse(): ${error.message}`, + }), + retryFetch + ); + } + onMessage(data); + } + + if (!done) { + buffer = lines[lines.length - 1]; + continue; + } + + onClose && onClose(); + return; + } + } catch (error) { + // @ts-ignore + if (error instanceof WebrpcStreamLostError) { + onError(error, retryFetch); + } else { + throw error; + } + } finally { + clearInterval(intervalId); + } }; -const createHTTPRequest = (body: object = {}, options?: WebrpcOptions): object => { - return { - method: 'POST', - headers: { ...options?.headers, 'Content-Type': 'application/json' }, - body: JSON.stringify(body || {}), - signal: options?.signal - }; -}; + + const createHTTPRequest = (body: object = {}, headers?: object, signal?: AbortSignal): object => { + return { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify(body || {}), + signal + } +} const buildResponse = (res: Response): Promise => { - return res.text().then((text) => { - let data; - try { - data = JSON.parse(text); - } catch (error) { - let message = ''; - if (error instanceof Error) { - message = error.message; - } - throw WebrpcBadResponseError.new({ - status: res.status, - cause: `JSON.parse(): ${message}: response text: ${text}` - }); - } - if (!res.ok) { - const code: number = typeof data.code === 'number' ? data.code : 0; - throw (webrpcErrorByCode[code] || WebrpcError).new(data); - } - return data; - }); -}; + return res.text().then(text => { + let data + try { + data = JSON.parse(text) + } catch(error) { + let message = '' + if (error instanceof Error) { + message = error.message + } + throw WebrpcBadResponseError.new({ + status: res.status, + cause: `JSON.parse(): ${message}: response text: ${text}`}, + ) + } + if (!res.ok) { + const code: number = (typeof data.code === 'number') ? data.code : 0 + throw (webrpcErrorByCode[code] || WebrpcError).new(data) + } + return data + }) +} // // Errors // export class WebrpcError extends Error { - name: string; - code: number; - message: string; - status: number; - cause?: string; - - /** @deprecated Use message instead of msg. Deprecated in webrpc v0.11.0. */ - msg: string; - - constructor(name: string, code: number, message: string, status: number, cause?: string) { - super(message); - this.name = name || 'WebrpcError'; - this.code = typeof code === 'number' ? code : 0; - this.message = message || `endpoint error ${this.code}`; - this.msg = this.message; - this.status = typeof status === 'number' ? status : 0; - this.cause = cause; - Object.setPrototypeOf(this, WebrpcError.prototype); - } - - static new(payload: any): WebrpcError { - return new this( - payload.error, - payload.code, - payload.message || payload.msg, - payload.status, - payload.cause - ); - } + name: string + code: number + message: string + status: number + cause?: string + + /** @deprecated Use message instead of msg. Deprecated in webrpc v0.11.0. */ + msg: string + + constructor(name: string, code: number, message: string, status: number, cause?: string) { + super(message) + this.name = name || 'WebrpcError' + this.code = typeof code === 'number' ? code : 0 + this.message = message || `endpoint error ${this.code}` + this.msg = this.message + this.status = typeof status === 'number' ? status : 0 + this.cause = cause + Object.setPrototypeOf(this, WebrpcError.prototype) + } + + static new(payload: any): WebrpcError { + return new this(payload.error, payload.code, payload.message || payload.msg, payload.status, payload.cause) + } } // Webrpc errors export class WebrpcEndpointError extends WebrpcError { - constructor( - name: string = 'WebrpcEndpoint', - code: number = 0, - message: string = `endpoint error`, - status: number = 0, - cause?: string - ) { - super(name, code, message, status, cause); - Object.setPrototypeOf(this, WebrpcEndpointError.prototype); - } + constructor( + name: string = 'WebrpcEndpoint', + code: number = 0, + message: string = 'endpoint error', + status: number = 0, + cause?: string + ) { + super(name, code, message, status, cause) + Object.setPrototypeOf(this, WebrpcEndpointError.prototype) + } } export class WebrpcRequestFailedError extends WebrpcError { - constructor( - name: string = 'WebrpcRequestFailed', - code: number = -1, - message: string = `request failed`, - status: number = 0, - cause?: string - ) { - super(name, code, message, status, cause); - Object.setPrototypeOf(this, WebrpcRequestFailedError.prototype); - } + constructor( + name: string = 'WebrpcRequestFailed', + code: number = -1, + message: string = 'request failed', + status: number = 0, + cause?: string + ) { + super(name, code, message, status, cause) + Object.setPrototypeOf(this, WebrpcRequestFailedError.prototype) + } } export class WebrpcBadRouteError extends WebrpcError { - constructor( - name: string = 'WebrpcBadRoute', - code: number = -2, - message: string = `bad route`, - status: number = 0, - cause?: string - ) { - super(name, code, message, status, cause); - Object.setPrototypeOf(this, WebrpcBadRouteError.prototype); - } + constructor( + name: string = 'WebrpcBadRoute', + code: number = -2, + message: string = 'bad route', + status: number = 0, + cause?: string + ) { + super(name, code, message, status, cause) + Object.setPrototypeOf(this, WebrpcBadRouteError.prototype) + } } export class WebrpcBadMethodError extends WebrpcError { - constructor( - name: string = 'WebrpcBadMethod', - code: number = -3, - message: string = `bad method`, - status: number = 0, - cause?: string - ) { - super(name, code, message, status, cause); - Object.setPrototypeOf(this, WebrpcBadMethodError.prototype); - } + constructor( + name: string = 'WebrpcBadMethod', + code: number = -3, + message: string = 'bad method', + status: number = 0, + cause?: string + ) { + super(name, code, message, status, cause) + Object.setPrototypeOf(this, WebrpcBadMethodError.prototype) + } } export class WebrpcBadRequestError extends WebrpcError { - constructor( - name: string = 'WebrpcBadRequest', - code: number = -4, - message: string = `bad request`, - status: number = 0, - cause?: string - ) { - super(name, code, message, status, cause); - Object.setPrototypeOf(this, WebrpcBadRequestError.prototype); - } + constructor( + name: string = 'WebrpcBadRequest', + code: number = -4, + message: string = 'bad request', + status: number = 0, + cause?: string + ) { + super(name, code, message, status, cause) + Object.setPrototypeOf(this, WebrpcBadRequestError.prototype) + } } export class WebrpcBadResponseError extends WebrpcError { - constructor( - name: string = 'WebrpcBadResponse', - code: number = -5, - message: string = `bad response`, - status: number = 0, - cause?: string - ) { - super(name, code, message, status, cause); - Object.setPrototypeOf(this, WebrpcBadResponseError.prototype); - } + constructor( + name: string = 'WebrpcBadResponse', + code: number = -5, + message: string = 'bad response', + status: number = 0, + cause?: string + ) { + super(name, code, message, status, cause) + Object.setPrototypeOf(this, WebrpcBadResponseError.prototype) + } } export class WebrpcServerPanicError extends WebrpcError { - constructor( - name: string = 'WebrpcServerPanic', - code: number = -6, - message: string = `server panic`, - status: number = 0, - cause?: string - ) { - super(name, code, message, status, cause); - Object.setPrototypeOf(this, WebrpcServerPanicError.prototype); - } + constructor( + name: string = 'WebrpcServerPanic', + code: number = -6, + message: string = 'server panic', + status: number = 0, + cause?: string + ) { + super(name, code, message, status, cause) + Object.setPrototypeOf(this, WebrpcServerPanicError.prototype) + } } export class WebrpcInternalErrorError extends WebrpcError { - constructor( - name: string = 'WebrpcInternalError', - code: number = -7, - message: string = `internal error`, - status: number = 0, - cause?: string - ) { - super(name, code, message, status, cause); - Object.setPrototypeOf(this, WebrpcInternalErrorError.prototype); - } + constructor( + name: string = 'WebrpcInternalError', + code: number = -7, + message: string = 'internal error', + status: number = 0, + cause?: string + ) { + super(name, code, message, status, cause) + Object.setPrototypeOf(this, WebrpcInternalErrorError.prototype) + } } export class WebrpcClientDisconnectedError extends WebrpcError { - constructor( - name: string = 'WebrpcClientDisconnected', - code: number = -8, - message: string = `client disconnected`, - status: number = 0, - cause?: string - ) { - super(name, code, message, status, cause); - Object.setPrototypeOf(this, WebrpcClientDisconnectedError.prototype); - } + constructor( + name: string = 'WebrpcClientDisconnected', + code: number = -8, + message: string = 'client disconnected', + status: number = 0, + cause?: string + ) { + super(name, code, message, status, cause) + Object.setPrototypeOf(this, WebrpcClientDisconnectedError.prototype) + } } export class WebrpcStreamLostError extends WebrpcError { - constructor( - name: string = 'WebrpcStreamLost', - code: number = -9, - message: string = `stream lost`, - status: number = 0, - cause?: string - ) { - super(name, code, message, status, cause); - Object.setPrototypeOf(this, WebrpcStreamLostError.prototype); - } + constructor( + name: string = 'WebrpcStreamLost', + code: number = -9, + message: string = 'stream lost', + status: number = 0, + cause?: string + ) { + super(name, code, message, status, cause) + Object.setPrototypeOf(this, WebrpcStreamLostError.prototype) + } } export class WebrpcStreamFinishedError extends WebrpcError { - constructor( - name: string = 'WebrpcStreamFinished', - code: number = -10, - message: string = `stream finished`, - status: number = 0, - cause?: string - ) { - super(name, code, message, status, cause); - Object.setPrototypeOf(this, WebrpcStreamFinishedError.prototype); - } + constructor( + name: string = 'WebrpcStreamFinished', + code: number = -10, + message: string = 'stream finished', + status: number = 0, + cause?: string + ) { + super(name, code, message, status, cause) + Object.setPrototypeOf(this, WebrpcStreamFinishedError.prototype) + } } + // Schema errors export class SnakeNotFoundError extends WebrpcError { - constructor( - name: string = 'SnakeNotFound', - code: number = 100, - message: string = `Snake not found.`, - status: number = 0, - cause?: string - ) { - super(name, code, message, status, cause); - Object.setPrototypeOf(this, SnakeNotFoundError.prototype); - } + constructor( + name: string = 'SnakeNotFound', + code: number = 100, + message: string = 'Snake not found.', + status: number = 0, + cause?: string + ) { + super(name, code, message, status, cause) + Object.setPrototypeOf(this, SnakeNotFoundError.prototype) + } } export class InvalidInputError extends WebrpcError { - constructor( - name: string = 'InvalidInput', - code: number = 101, - message: string = `Invalid input.`, - status: number = 0, - cause?: string - ) { - super(name, code, message, status, cause); - Object.setPrototypeOf(this, InvalidInputError.prototype); - } + constructor( + name: string = 'InvalidInput', + code: number = 101, + message: string = 'Invalid input.', + status: number = 0, + cause?: string + ) { + super(name, code, message, status, cause) + Object.setPrototypeOf(this, InvalidInputError.prototype) + } } export class TurnAboutError extends WebrpcError { - constructor( - name: string = 'TurnAbout', - code: number = 200, - message: string = `Turnabout is not allowed.`, - status: number = 0, - cause?: string - ) { - super(name, code, message, status, cause); - Object.setPrototypeOf(this, TurnAboutError.prototype); - } + constructor( + name: string = 'TurnAbout', + code: number = 200, + message: string = 'Turnabout is not allowed.', + status: number = 0, + cause?: string + ) { + super(name, code, message, status, cause) + Object.setPrototypeOf(this, TurnAboutError.prototype) + } } export class TurnSameDirectionError extends WebrpcError { - constructor( - name: string = 'TurnSameDirection', - code: number = 201, - message: string = `Duplicated turn. Same direction.`, - status: number = 0, - cause?: string - ) { - super(name, code, message, status, cause); - Object.setPrototypeOf(this, TurnSameDirectionError.prototype); - } + constructor( + name: string = 'TurnSameDirection', + code: number = 201, + message: string = 'Duplicated turn. Same direction.', + status: number = 0, + cause?: string + ) { + super(name, code, message, status, cause) + Object.setPrototypeOf(this, TurnSameDirectionError.prototype) + } } + export enum errors { - WebrpcEndpoint = 'WebrpcEndpoint', - WebrpcRequestFailed = 'WebrpcRequestFailed', - WebrpcBadRoute = 'WebrpcBadRoute', - WebrpcBadMethod = 'WebrpcBadMethod', - WebrpcBadRequest = 'WebrpcBadRequest', - WebrpcBadResponse = 'WebrpcBadResponse', - WebrpcServerPanic = 'WebrpcServerPanic', - WebrpcInternalError = 'WebrpcInternalError', - WebrpcClientDisconnected = 'WebrpcClientDisconnected', - WebrpcStreamLost = 'WebrpcStreamLost', - WebrpcStreamFinished = 'WebrpcStreamFinished', - SnakeNotFound = 'SnakeNotFound', - InvalidInput = 'InvalidInput', - TurnAbout = 'TurnAbout', - TurnSameDirection = 'TurnSameDirection' + WebrpcEndpoint = 'WebrpcEndpoint', + WebrpcRequestFailed = 'WebrpcRequestFailed', + WebrpcBadRoute = 'WebrpcBadRoute', + WebrpcBadMethod = 'WebrpcBadMethod', + WebrpcBadRequest = 'WebrpcBadRequest', + WebrpcBadResponse = 'WebrpcBadResponse', + WebrpcServerPanic = 'WebrpcServerPanic', + WebrpcInternalError = 'WebrpcInternalError', + WebrpcClientDisconnected = 'WebrpcClientDisconnected', + WebrpcStreamLost = 'WebrpcStreamLost', + WebrpcStreamFinished = 'WebrpcStreamFinished', + SnakeNotFound = 'SnakeNotFound', + InvalidInput = 'InvalidInput', + TurnAbout = 'TurnAbout', + TurnSameDirection = 'TurnSameDirection', } const webrpcErrorByCode: { [code: number]: any } = { - [0]: WebrpcEndpointError, - [-1]: WebrpcRequestFailedError, - [-2]: WebrpcBadRouteError, - [-3]: WebrpcBadMethodError, - [-4]: WebrpcBadRequestError, - [-5]: WebrpcBadResponseError, - [-6]: WebrpcServerPanicError, - [-7]: WebrpcInternalErrorError, - [-8]: WebrpcClientDisconnectedError, - [-9]: WebrpcStreamLostError, - [-10]: WebrpcStreamFinishedError, - [100]: SnakeNotFoundError, - [101]: InvalidInputError, - [200]: TurnAboutError, - [201]: TurnSameDirectionError -}; + [0]: WebrpcEndpointError, + [-1]: WebrpcRequestFailedError, + [-2]: WebrpcBadRouteError, + [-3]: WebrpcBadMethodError, + [-4]: WebrpcBadRequestError, + [-5]: WebrpcBadResponseError, + [-6]: WebrpcServerPanicError, + [-7]: WebrpcInternalErrorError, + [-8]: WebrpcClientDisconnectedError, + [-9]: WebrpcStreamLostError, + [-10]: WebrpcStreamFinishedError, + [100]: SnakeNotFoundError, + [101]: InvalidInputError, + [200]: TurnAboutError, + [201]: TurnSameDirectionError, +} -export type Fetch = (input: RequestInfo, init?: RequestInit) => Promise; +export type Fetch = (input: RequestInfo, init?: RequestInit) => Promise +export interface WebrpcStreamOptions extends WebrpcOptions { + onMessage: (message: T) => void; + onError: (error: WebrpcError, reconnect: () => void) => void; + onOpen?: () => void; + onClose?: () => void; +} export interface WebrpcOptions { - headers?: HeadersInit; - signal?: AbortSignal; + headers?: HeadersInit; + signal?: AbortSignal; } -export interface WebrpcStreamOptions extends WebrpcOptions { - onMessage: (message: T) => void; - onError: (error: WebrpcError, reconnect?: () => void) => void; - onOpen?: () => void; - onClose?: () => void; -}