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()
|
2024-02-03 09:26:46 -06:00
|
|
|
// 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)
|
2024-02-03 22:34:29 -06:00
|
|
|
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")
|
2024-02-03 22:34:29 -06:00
|
|
|
g.Expire()
|
2024-02-03 22:48:23 -06:00
|
|
|
broadcast(ctx, g, dealer, chall, obs, dl)
|
2024-02-03 22:34:29 -06:00
|
|
|
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) {
|
2024-02-04 09:42:42 -06:00
|
|
|
// 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")
|
|
|
|
)
|