package main import ( "context" "errors" "log/slog" "time" "nhooyr.io/websocket" "nhooyr.io/websocket/wsjson" "git.sunturtle.xyz/studio/shotgun/game" "git.sunturtle.xyz/studio/shotgun/player" ) // An action is a request to change the game state. type action struct { Action string `json:"action"` Player player.ID `json:"-"` } func applyAction(g *game.Match, a action) error { switch a.Action { case "quit": return g.Concede(a.Player) case "across", "self": return g.Shoot(a.Player, a.Action == "self") case "0": return g.Apply(a.Player, 0) case "1": return g.Apply(a.Player, 1) case "2": return g.Apply(a.Player, 2) case "3": return g.Apply(a.Player, 3) case "4": return g.Apply(a.Player, 4) case "5": return g.Apply(a.Player, 5) case "6": return g.Apply(a.Player, 6) case "7": return g.Apply(a.Player, 7) default: return errWeirdAction } } // gameActor is an actor that updates a game's state and relays changes to all // observers. func gameActor(ctx context.Context, g *game.Match, dealer, chall person, join <-chan person) { // Games should generally be on the order of minutes. A four hour game is // definitely expired. ctx, stop := context.WithTimeoutCause(ctx, 4*time.Hour, errMatchExpired) defer stop() var obs []person actions := make(chan action, 2) go playerActor(ctx, dealer, actions) go playerActor(ctx, chall, actions) // TODO(zeph): send round start info slog.InfoContext(ctx, "start game", "dealer", dealer.id, "challenger", chall.id) for { select { case <-ctx.Done(): return case p := <-join: slog.DebugContext(ctx, "observer", "id", p.id) // Deliver the game state just to the new observer. if err := wsjson.Write(ctx, p.conn, g.DTO(p.id)); err != nil { // We don't need to track them as an observer. Just close their // conn and move on. go p.conn.Close(websocket.StatusNormalClosure, "looks like you dropped") continue } obs = append(obs, p) // TODO(zeph): CloseRead returns a context we can use to drop the // observer when they disconnect p.conn.CloseRead(ctx) case a := <-actions: slog.DebugContext(ctx, "got action", "from", a.Player, "action", a.Action) err := applyAction(g, a) switch err { case nil: broadcast(ctx, g, dealer, chall, obs) case game.ErrRoundEnded: broadcast(ctx, g, dealer, chall, obs) if g.MatchWinner() != nil { gameOver(ctx, dealer, chall, obs) // TODO(zeph): server needs to know the players are gone return } g.NextRound() broadcast(ctx, g, dealer, chall, obs) case game.ErrGameEnded: broadcast(ctx, g, dealer, chall, obs) g.NextGame() broadcast(ctx, g, dealer, chall, obs) case game.ErrWrongTurn: // do nothing case errWeirdAction: slog.WarnContext(ctx, "nonsense action", "from", a.Player, "action", a.Action) default: slog.ErrorContext(ctx, "action caused a mystery error", "from", a.Player, "action", a.Action, "err", err.Error()) } } } } func playerActor(ctx context.Context, p person, actions chan<- action) { for { a := action{Player: p.id} if err := wsjson.Read(ctx, p.conn, &a); err != nil { // If we fail to read, then we consider them to have quit. a.Action = "quit" select { case <-ctx.Done(): case actions <- a: slog.DebugContext(ctx, "lost connection", "player", p.id, "err", err.Error()) } return } select { case <-ctx.Done(): return case actions <- a: slog.DebugContext(ctx, "action", "action", a) } } } func broadcast(ctx context.Context, g *game.Match, dealer, chall person, obs []person) { // TODO(zeph): this probably should return an error or some other signal // if a player drops so that the actor knows to quit if err := wsjson.Write(ctx, dealer.conn, g.DTO(dealer.id)); err != nil { // TODO(zeph): concede, but we need to be careful not to recurse slog.DebugContext(ctx, "lost dealer", "player", dealer.id, "err", err.Error()) } if err := wsjson.Write(ctx, chall.conn, g.DTO(chall.id)); err != nil { // TODO(zeph): concede, but we need to be careful not to recurse slog.DebugContext(ctx, "lost challenger", "player", chall.id, "err", err.Error()) } for _, p := range obs { if err := wsjson.Write(ctx, p.conn, g.DTO(p.id)); err != nil { slog.DebugContext(ctx, "lost observer", "player", p.id, "err", err.Error()) } } } func gameOver(ctx context.Context, dealer, chall person, obs []person) { // TODO(zeph): need to communicate to the server that these have gone away go dealer.conn.Close(websocket.StatusNormalClosure, "match ended") go chall.conn.Close(websocket.StatusNormalClosure, "match ended") for _, p := range obs { c := p.conn go c.Close(websocket.StatusNormalClosure, "match ended") } } var ( errWeirdAction = errors.New("unknown action") errMatchExpired = errors.New("there is a time limit on matches please") )