shotgun/game.go

195 lines
5.4 KiB
Go
Raw Normal View History

2024-01-26 11:28:14 -06:00
package main
import (
"context"
"errors"
2024-01-27 13:51:20 -06:00
"log/slog"
2024-01-26 11:28:14 -06:00
"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:"-"`
}
2024-01-29 13:12:25 -06:00
func applyAction(g *game.Match, a action) error {
switch a.Action {
case "quit":
return g.Concede(a.Player)
case "across", "self":
2024-01-31 07:36:31 -06:00
return g.Shoot(a.Player, a.Action == "self")
2024-01-29 13:12:25 -06:00
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
}
}
2024-01-31 07:51:14 -06:00
type observer struct {
conn *websocket.Conn
ctx context.Context
}
2024-01-26 11:28:14 -06:00
// gameActor is an actor that updates a game's state and relays changes to all
// observers.
2024-01-28 12:42:42 -06:00
func gameActor(ctx context.Context, g *game.Match, dealer, chall person, join <-chan person) {
2024-01-26 11:28:14 -06:00
// Games should generally be on the order of minutes. A four hour game is
// definitely expired.
2024-01-29 13:12:25 -06:00
ctx, stop := context.WithTimeoutCause(ctx, 4*time.Hour, errMatchExpired)
2024-01-26 11:28:14 -06:00
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
2024-01-31 07:51:14 -06:00
var obs []observer
2024-01-26 11:28:14 -06:00
actions := make(chan action, 2)
go playerActor(ctx, dealer, actions)
go playerActor(ctx, chall, actions)
2024-02-02 19:13:30 -06:00
2024-01-27 13:51:20 -06:00
slog.InfoContext(ctx, "start game", "dealer", dealer.id, "challenger", chall.id)
2024-02-03 21:11:09 -06:00
const timeLimit = 15 * time.Second
dl := time.Now().Add(timeLimit)
broadcast(ctx, g, dealer, chall, obs, dl)
tick := time.NewTicker(timeLimit)
defer tick.Stop()
2024-01-26 11:28:14 -06:00
for {
select {
case <-ctx.Done():
return
case p := <-join:
2024-02-02 18:40:57 -06:00
slog.InfoContext(ctx, "observer", "id", p.id)
2024-01-26 11:28:14 -06:00
// Deliver the game state just to the new observer.
2024-02-03 21:11:09 -06:00
if err := wsjson.Write(ctx, p.conn, g.DTO(p.id, dl)); err != nil {
2024-01-26 11:28:14 -06:00
// 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
}
2024-01-31 07:51:14 -06:00
q := p.conn.CloseRead(ctx)
obs = append(obs, observer{conn: p.conn, ctx: q})
2024-01-26 11:28:14 -06:00
case a := <-actions:
2024-02-02 18:40:57 -06:00
slog.InfoContext(ctx, "got action", "from", a.Player, "action", a.Action)
2024-01-29 13:12:25 -06:00
err := applyAction(g, a)
switch err {
case nil:
2024-02-03 21:11:09 -06:00
broadcast(ctx, g, dealer, chall, obs, dl)
2024-01-31 07:36:31 -06:00
case game.ErrRoundEnded:
2024-02-03 21:11:09 -06:00
broadcast(ctx, g, dealer, chall, obs, dl)
2024-01-29 13:12:25 -06:00
if g.MatchWinner() != nil {
gameOver(ctx, dealer, chall, obs)
return
}
g.NextRound()
2024-02-03 21:11:09 -06:00
broadcast(ctx, g, dealer, chall, obs, dl)
2024-01-31 07:36:31 -06:00
case game.ErrGameEnded:
2024-02-03 21:11:09 -06:00
broadcast(ctx, g, dealer, chall, obs, dl)
2024-01-31 07:36:31 -06:00
g.NextGame()
2024-02-03 21:11:09 -06:00
broadcast(ctx, g, dealer, chall, obs, dl)
2024-02-03 14:26:14 -06:00
case game.ErrWrongTurn:
slog.WarnContext(ctx, "action on wrong turn", "from", a.Player, "action", a.Action)
2024-02-03 21:11:09 -06:00
continue
2024-01-29 13:12:25 -06:00
case errWeirdAction:
slog.WarnContext(ctx, "nonsense action", "from", a.Player, "action", a.Action)
2024-02-03 21:11:09 -06:00
continue
2024-01-29 13:12:25 -06:00
default:
slog.ErrorContext(ctx, "action caused a mystery error", "from", a.Player, "action", a.Action, "err", err.Error())
2024-02-03 21:11:09 -06:00
continue
2024-01-29 13:12:25 -06:00
}
2024-02-03 21:11:09 -06:00
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
2024-01-26 11:28:14 -06:00
}
2024-01-31 07:51:14 -06:00
// 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]
}
}
2024-01-26 11:28:14 -06:00
}
}
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:
2024-02-02 18:40:57 -06:00
slog.InfoContext(ctx, "lost connection", "player", p.id, "err", err.Error())
2024-01-26 11:28:14 -06:00
}
return
}
2024-02-02 21:07:30 -06:00
if a.Action == "ping" {
continue
}
2024-01-26 11:28:14 -06:00
select {
case <-ctx.Done():
return
case actions <- a:
}
}
}
2024-02-03 21:11:09 -06:00
func broadcast(ctx context.Context, g *game.Match, dealer, chall person, obs []observer, deadline time.Time) {
// NOTE(zeph): If our sends to players fail, then the socket will close,
// causing the readers to fail and send concede messages. No leak.
2024-02-03 21:11:09 -06:00
if err := wsjson.Write(ctx, dealer.conn, g.DTO(dealer.id, deadline)); err != nil {
2024-02-03 14:26:14 -06:00
slog.WarnContext(ctx, "lost dealer", "player", dealer.id, "err", err.Error())
2024-01-26 11:28:14 -06:00
}
2024-02-03 21:11:09 -06:00
if err := wsjson.Write(ctx, chall.conn, g.DTO(chall.id, deadline)); err != nil {
2024-02-03 14:26:14 -06:00
slog.WarnContext(ctx, "lost challenger", "player", chall.id, "err", err.Error())
2024-01-26 11:28:14 -06:00
}
2024-01-31 07:51:14 -06:00
if len(obs) == 0 {
return
}
2024-02-03 21:11:09 -06:00
d := g.DTO(player.ID{}, deadline)
2024-01-26 11:28:14 -06:00
for _, p := range obs {
2024-01-31 07:51:14 -06:00
if err := wsjson.Write(ctx, p.conn, &d); err != nil {
2024-02-03 14:26:14 -06:00
slog.WarnContext(ctx, "lost observer", "err", err.Error())
2024-01-26 11:28:14 -06:00
}
}
}
2024-01-31 07:51:14 -06:00
func gameOver(ctx context.Context, dealer, chall person, obs []observer) {
2024-01-29 13:12:25 -06:00
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")
)