diff --git a/game.go b/game.go index 3e52c22..bf3b84b 100644 --- a/game.go +++ b/game.go @@ -22,7 +22,7 @@ type action struct { // gameActor is an actor that updates a game's state and relays changes to all // observers. -func gameActor(ctx context.Context, g *game.Game, dealer, chall person, join <-chan person) { +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, errGameExpired) @@ -80,7 +80,7 @@ func playerActor(ctx context.Context, p person, actions chan<- action) { } } -func broadcast(ctx context.Context, g *game.Game, dealer, chall person, obs []person) { +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 { diff --git a/game/game.go b/game/game.go index 86d85b7..6461719 100644 --- a/game/game.go +++ b/game/game.go @@ -1,3 +1,12 @@ +// Package game implements a controller for Buckshot Roulette matches. +// +// The terminology of this package is as follows: +// - A match is a pairing of two players, the dealer and challenger. +// It ends when the challenger wins three rounds or the dealer wins once. +// - A round is an instance of gameplay. Both players start each round with +// the same HP, and it continues until either player reaches zero. +// - A game is a set of 2-8 shells and 1-4 items delivered to each player. +// The challenger always moves first at the start of each game. package game import ( @@ -7,47 +16,61 @@ import ( "git.sunturtle.xyz/studio/shotgun/serve" ) -type Game struct { - rng RNG +type Match struct { + // rng is the PRNG state used for this match. + rng RNG + // players are the players in the match. The first element is the dealer + // and the second is the challenger. players [2]Player - shells []bool - round uint - group uint - turn uint - hp int8 - damage int8 - reveal bool - prev *bool + // shells is the list of remaining shells in the current game. It always + // uses shellArray as its backing array. + shells []bool + // round is the round number. Once it reaches 3, the match is over. + round uint + // game is the game number. May be nice for metrics eventually. + game uint + // turn is the turn number. When even, it is the dealer's turn. + turn uint + // hp is the starting and max HP of both players in the current round. + hp int8 + // damage is the amount of damage a live shell will deal this turn. + damage int8 + // reveal indicates whether the current player has revealed the shell that + // will fire next. + reveal bool + // prev is a pointer to the element of shellArray which was fired last this + // game, or nil if none has been. + prev *bool - // Backing storage for shells. + // shellArray is the backing storage for shells and prev. shellArray [8]bool } -// New creates a new game started at round 1. -func New(dealer, challenger player.ID) *Game { - g := &Game{ +// New creates a new match started at round 1. +func New(dealer, challenger player.ID) *Match { + g := &Match{ rng: NewRNG(), players: [2]Player{ {id: dealer}, {id: challenger}, }, } - g.StartRound() + g.NextRound() return g } -// StartRound starts the next round of a game. -func (g *Game) StartRound() { +// NextRound starts the next round of a match. +func (g *Match) NextRound() { g.hp = int8(g.rng.Intn(3) + 2) g.players[0].StartRound(g.hp) g.players[1].StartRound(g.hp) g.round++ - g.group = 0 - g.StartGroup() + g.game = 0 + g.NextGame() } -// StartGroup starts the next shell group of a round. -func (g *Game) StartGroup() { +// NextGame starts the next game of a round. +func (g *Match) NextGame() { items := g.rng.Intn(4) + 1 g.players[0].StartGroup(&g.rng, items) g.players[1].StartGroup(&g.rng, items) @@ -60,14 +83,14 @@ func (g *Game) StartGroup() { } g.shells = g.shellArray[:shells] ShuffleSlice(&g.rng, g.shells) - g.group++ + g.game++ g.turn = 0 g.prev = nil g.NextTurn() } -// NextTurn advances the turn. -func (g *Game) NextTurn() { +// NextTurn advances the turn but not the match or round state. +func (g *Match) NextTurn() { g.turn++ g.damage = 1 g.reveal = false @@ -79,20 +102,21 @@ func (g *Game) NextTurn() { } } -// CurrentPlayer gets the index of the current player, either 0 or 1. -func (g *Game) CurrentPlayer() *Player { +// CurrentPlayer gets the current player. +func (g *Match) CurrentPlayer() *Player { return &g.players[g.turn%2] } // Opponent returns the player who is not the current player. -func (g *Game) Opponent() *Player { +func (g *Match) Opponent() *Player { return &g.players[g.turn%2^1] } // Apply uses an item by index for the current player. -// This may cause the group to end. +// This may cause the current game to end, but does not start the next game +// if so. // Returns ErrWrongTurn if id does not correspond to the current player. -func (g *Game) Apply(id player.ID, item int) error { +func (g *Match) Apply(id player.ID, item int) error { cur := g.CurrentPlayer() if cur.id != id { return ErrWrongTurn @@ -106,10 +130,10 @@ func (g *Game) Apply(id player.ID, item int) error { return nil } -// PopShell removes a shell from the shotgun. -// This may cause the shotgun to be empty, but does not start the next group +// popShell removes a shell from the shotgun. +// This may cause the shotgun to be empty, but does not start the next game // if so. -func (g *Game) PopShell() bool { +func (g *Match) popShell() bool { g.prev = &g.shells[0] g.shells = g.shells[1:] return *g.prev @@ -117,7 +141,7 @@ func (g *Game) PopShell() bool { // Peek returns the current turn's shell if it is revealed for the player with // the given ID, or nil otherwise. -func (g *Game) Peek(id player.ID) *bool { +func (g *Match) Peek(id player.ID) *bool { if len(g.shells) == 0 || id != g.CurrentPlayer().id || !g.reveal { return nil } @@ -125,12 +149,13 @@ func (g *Game) Peek(id player.ID) *bool { } // Empty returns whether the shotgun is empty. -func (g *Game) Empty() bool { +func (g *Match) Empty() bool { return len(g.shells) == 0 } -// Winner returns the player who won, or nil if the round is not over. -func (g *Game) Winner() *Player { +// Winner returns the player who won the current round, or nil if the round is +// not over. +func (g *Match) Winner() *Player { if g.players[0].hp <= 0 { return &g.players[1] } @@ -141,10 +166,9 @@ func (g *Game) Winner() *Player { } // Shoot fires the shotgun, at the opponent if self is false and at the current -// player if self is true. Afterward, the round may be over, or the shotgun may -// be empty. +// player if self is true. Afterward, the round or game may be over. // Returns ErrWrongTurn if id does not correspond to the current player. -func (g *Game) Shoot(id player.ID, self bool) error { +func (g *Match) Shoot(id player.ID, self bool) error { cur := g.CurrentPlayer() if cur.id != id { return ErrWrongTurn @@ -153,7 +177,7 @@ func (g *Game) Shoot(id player.ID, self bool) error { if self { target = g.CurrentPlayer() } - live := g.PopShell() + live := g.popShell() if live { target.hp -= g.damage g.NextTurn() @@ -163,10 +187,10 @@ func (g *Game) Shoot(id player.ID, self bool) error { return nil } -// Concede sets the player with the given ID to zero health. The returned -// Boolean indicates whether the game has ended. (It will be false if id does -// not correspond to either player.) -func (g *Game) Concede(id player.ID) bool { +// Concede sets the player with the given ID to zero health and ends the match. +// The returned bool indicates whether the match has ended. (It will be false +// if id does not correspond to either player.) +func (g *Match) Concede(id player.ID) bool { switch id { case g.players[0].id: g.players[0].hp = 0 @@ -175,11 +199,12 @@ func (g *Game) Concede(id player.ID) bool { default: return false } + g.round = 3 return true } -// DTO returns the current game state as viewed by the given player. -func (g *Game) DTO(id player.ID) serve.Game { +// DTO returns the current match state as viewed by the given player. +func (g *Match) DTO(id player.ID) serve.Game { return serve.Game{ Players: [2]serve.Player{ g.players[0].DTO(), @@ -193,7 +218,7 @@ func (g *Game) DTO(id player.ID) serve.Game { } // ShellCounts returns the number of live and blank shells. -func (g *Game) ShellCounts() serve.ShellCounts { +func (g *Match) ShellCounts() serve.ShellCounts { var counts serve.ShellCounts for _, s := range g.shells { if s { diff --git a/game/game_test.go b/game/game_test.go index e330b16..b361c16 100644 --- a/game/game_test.go +++ b/game/game_test.go @@ -25,7 +25,7 @@ func TestNewGame(t *testing.T) { {len(g.shells) < 2 || len(g.shells) > 8, "bad shells count %d, want 2-8", []any{len(g.shells)}}, {&g.shells[0] != &g.shellArray[0], "shells[0] is %p, want %p", []any{&g.shells[0], &g.shellArray[0]}}, {g.round != 1, "first round is %d, want 1", []any{g.round}}, - {g.group != 1, "first group is %d, want 1", []any{g.group}}, + {g.game != 1, "first group is %d, want 1", []any{g.game}}, {g.turn != 1, "first turn is %d, want 1", []any{g.turn}}, {g.hp < 2 || g.hp > 4, "hp is %d, want 2-4", []any{g.hp}}, {g.damage != 1, "damage is %d, want 1", []any{g.damage}}, @@ -54,7 +54,7 @@ func TestGameStartRound(t *testing.T) { {len(g.shells) < 2 || len(g.shells) > 8, "bad shells count %d, want 2-8", []any{len(g.shells)}}, {&g.shells[0] != &g.shellArray[0], "shells[0] is %p, want %p", []any{&g.shells[0], &g.shellArray[0]}}, {g.round != i, "round is %d, want %d", []any{g.round, i}}, - {g.group != 1, "group is %d, want 1", []any{g.group}}, + {g.game != 1, "group is %d, want 1", []any{g.game}}, {g.turn != 1, "turn is %d, want 1", []any{g.turn}}, {g.hp < 2 || g.hp > 4, "hp is %d, want 2-4", []any{g.hp}}, {g.damage != 1, "damage is %d, want 1", []any{g.damage}}, @@ -68,13 +68,13 @@ func TestGameStartRound(t *testing.T) { } // H*ck with the game state as if a round is played. g.players[0].hp = 0 - g.PopShell() - g.PopShell() - g.group = 2 + g.popShell() + g.popShell() + g.game = 2 g.turn = 3 g.damage = 2 // Start the next round and check again. - g.StartRound() + g.NextRound() } } @@ -94,7 +94,7 @@ func TestGameStartGroup(t *testing.T) { {&g.shells[0] != &g.shellArray[0], "shells[0] is %p, want %p", []any{&g.shells[0], &g.shellArray[0]}}, {counts.Live != counts.Blank && counts.Live+1 != counts.Blank, "imbalanced live/blank %d/%d", []any{counts.Live, counts.Blank}}, {g.round != 1, "round is %d, want 1", []any{g.round}}, - {g.group != i, "group is %d, want %d", []any{g.group, i}}, + {g.game != i, "group is %d, want %d", []any{g.game, i}}, {g.turn != 1, "turn is %d, want 1", []any{g.turn}}, {g.damage != 1, "damage is %d, want 1", []any{g.damage}}, {g.reveal, "revealed at start", nil}, @@ -108,13 +108,13 @@ func TestGameStartGroup(t *testing.T) { // H*ck with the game state. g.players[0].items[0] = ItemNone g.players[1].items[0] = ItemNone - g.PopShell() - g.PopShell() + g.popShell() + g.popShell() g.turn = 3 g.damage = 2 g.reveal = true // Now advance the group. - g.StartGroup() + g.NextGame() } } @@ -128,7 +128,7 @@ func TestGameNextTurn(t *testing.T) { args []any }{ {g.round != 1, "round is %d, want 1", []any{g.round}}, - {g.group != 1, "group is %d, want 1", []any{g.group}}, + {g.game != 1, "group is %d, want 1", []any{g.game}}, {g.turn != i, "turn is %d, want %d", []any{g.turn, i}}, {g.damage != 1, "damage is %d, want 1", []any{g.damage}}, {g.reveal, "revealed at start", nil}, @@ -191,13 +191,13 @@ func TestGamePlayers(t *testing.T) { func TestGamePopShell(t *testing.T) { t.Parallel() g := New(player.ID{1}, player.ID{2}) - if live := g.PopShell(); live != g.shellArray[0] { + if live := g.popShell(); live != g.shellArray[0] { t.Errorf("first pop %t, wanted %t", live, g.shellArray[0]) } if g.prev != &g.shellArray[0] { t.Errorf("first prev is %p, wanted %p", g.prev, &g.shellArray[0]) } - if live := g.PopShell(); live != g.shellArray[1] { + if live := g.popShell(); live != g.shellArray[1] { t.Errorf("second pop %t, wanted %t", live, g.shellArray[1]) } if g.prev != &g.shellArray[1] { @@ -207,7 +207,7 @@ func TestGamePopShell(t *testing.T) { if g.Empty() { return } - g.PopShell() + g.popShell() } if !g.Empty() { t.Errorf("popped too many shells") @@ -221,7 +221,7 @@ func TestGamePeek(t *testing.T) { t.Parallel() g := New(dealer, chall) for !g.Empty() { - g.PopShell() + g.popShell() } if g.Peek(dealer) != nil || g.Peek(chall) != nil { t.Errorf("peeked a shell when empty") @@ -268,7 +268,7 @@ func TestGameEmpty(t *testing.T) { } for _, c := range cases { // We don't care about anything but the shells list. - g := Game{shells: c.shells} + g := Match{shells: c.shells} if got := g.Empty(); got != c.want { t.Errorf("empty reported %t, wanted %t", got, c.want) } diff --git a/game/item.go b/game/item.go index 9b4ecdd..77b65e5 100644 --- a/game/item.go +++ b/game/item.go @@ -15,7 +15,7 @@ func NewItem(rng *RNG) Item { return Item(rng.Intn(5)) + 1 } -func (i Item) Apply(g *Game) bool { +func (i Item) Apply(g *Match) bool { switch i { case ItemNone: return false @@ -33,9 +33,9 @@ func (i Item) Apply(g *Game) bool { } return true case ItemBeer: - g.PopShell() + g.popShell() if g.Empty() { - g.StartGroup() + g.NextGame() } return true case ItemCuff: