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 } } type observer struct { conn *websocket.Conn ctx context.Context } // 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() // Accept joins from the dealer and challenger. We don't care which is // which, but we need to get both to synchronize the socket. <-join <-join var obs []observer actions := make(chan action, 2) go playerActor(ctx, dealer, actions) go playerActor(ctx, chall, actions) slog.InfoContext(ctx, "start game", "dealer", dealer.id, "challenger", chall.id) const timeLimit = 15 * time.Second dl := time.Now().Add(timeLimit) broadcast(ctx, g, dealer, chall, obs, dl) tick := time.NewTicker(timeLimit) defer tick.Stop() for { select { case <-ctx.Done(): return case p := <-join: slog.InfoContext(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, dl)); 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 } q := p.conn.CloseRead(ctx) obs = append(obs, observer{conn: p.conn, ctx: q}) case a := <-actions: slog.InfoContext(ctx, "got action", "from", a.Player, "action", a.Action) err := applyAction(g, a) switch err { case nil: broadcast(ctx, g, dealer, chall, obs, dl) case game.ErrRoundEnded: broadcast(ctx, g, dealer, chall, obs, dl) 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, dl) case game.ErrGameEnded: broadcast(ctx, g, dealer, chall, obs, dl) g.NextGame() broadcast(ctx, g, dealer, chall, obs, dl) case game.ErrWrongTurn: slog.WarnContext(ctx, "action on wrong turn", "from", a.Player, "action", a.Action) continue case errWeirdAction: slog.WarnContext(ctx, "nonsense action", "from", a.Player, "action", a.Action) continue default: slog.ErrorContext(ctx, "action caused a mystery error", "from", a.Player, "action", a.Action, "err", err.Error()) continue } dl = time.Now().Add(timeLimit) tick.Reset(timeLimit) case <-tick.C: slog.InfoContext(ctx, "out of time") g.Expire() broadcast(ctx, g, dealer, chall, obs, dl) gameOver(ctx, dealer, chall, obs) return } // Clear observers who have left. for k := len(obs) - 1; k >= 0; k-- { if obs[k].ctx.Err() != nil { obs[k], obs[len(obs)-1] = obs[len(obs)-1], observer{} obs = obs[:len(obs)-1] } } } } 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.InfoContext(ctx, "lost connection", "player", p.id, "err", err.Error()) } return } if a.Action == "ping" { continue } select { case <-ctx.Done(): return case actions <- a: } } } func broadcast(ctx context.Context, g *game.Match, dealer, chall person, obs []observer, deadline time.Time) { // 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, deadline)); err != nil { // TODO(zeph): concede, but we need to be careful not to recurse slog.WarnContext(ctx, "lost dealer", "player", dealer.id, "err", err.Error()) } if err := wsjson.Write(ctx, chall.conn, g.DTO(chall.id, deadline)); err != nil { // TODO(zeph): concede, but we need to be careful not to recurse slog.WarnContext(ctx, "lost challenger", "player", chall.id, "err", err.Error()) } if len(obs) == 0 { return } d := g.DTO(player.ID{}, deadline) for _, p := range obs { if err := wsjson.Write(ctx, p.conn, &d); err != nil { slog.WarnContext(ctx, "lost observer", "err", err.Error()) } } } func gameOver(ctx context.Context, dealer, chall person, obs []observer) { // 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") )