shotgun/game.go

176 lines
5.0 KiB
Go

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()
var obs []observer
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.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)); 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)
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())
}
}
// 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
}
select {
case <-ctx.Done():
return
case actions <- a:
slog.InfoContext(ctx, "action", "action", a)
}
}
}
func broadcast(ctx context.Context, g *game.Match, dealer, chall person, obs []observer) {
// 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.InfoContext(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.InfoContext(ctx, "lost challenger", "player", chall.id, "err", err.Error())
}
if len(obs) == 0 {
return
}
d := g.DTO(player.ID{})
for _, p := range obs {
if err := wsjson.Write(ctx, p.conn, &d); err != nil {
slog.InfoContext(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")
)