shotgun/game/game.go

234 lines
5.9 KiB
Go
Raw Normal View History

2024-01-28 12:42:42 -06:00
// 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.
2024-01-20 22:06:56 -06:00
package game
import (
"errors"
"git.sunturtle.xyz/studio/shotgun/player"
2024-01-21 00:56:45 -06:00
"git.sunturtle.xyz/studio/shotgun/serve"
)
2024-01-28 12:42:42 -06:00
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.
2024-01-21 04:16:48 -06:00
players [2]Player
2024-01-28 12:42:42 -06:00
// 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
// shellArray is the backing storage for shells and prev.
2024-01-21 04:16:48 -06:00
shellArray [8]bool
2024-01-20 22:06:56 -06:00
}
2024-01-28 12:42:42 -06:00
// New creates a new match started at round 1.
func New(dealer, challenger player.ID) *Match {
g := &Match{
2024-01-21 04:16:48 -06:00
rng: NewRNG(),
players: [2]Player{
{id: dealer},
{id: challenger},
2024-01-21 04:15:03 -06:00
},
}
2024-01-28 12:42:42 -06:00
g.NextRound()
return g
2024-01-20 22:06:56 -06:00
}
2024-01-28 12:42:42 -06:00
// NextRound starts the next round of a match.
func (g *Match) NextRound() {
2024-01-21 04:16:48 -06:00
g.hp = int8(g.rng.Intn(3) + 2)
g.players[0].StartRound(g.hp)
g.players[1].StartRound(g.hp)
g.round++
2024-01-28 12:42:42 -06:00
g.game = 0
g.NextGame()
}
2024-01-28 12:42:42 -06:00
// NextGame starts the next game of a round.
func (g *Match) NextGame() {
2024-01-21 04:16:48 -06:00
items := g.rng.Intn(4) + 1
g.players[0].StartGroup(&g.rng, items)
g.players[1].StartGroup(&g.rng, items)
shells := g.rng.Intn(6) + 2
2024-01-20 22:06:56 -06:00
for i := 0; i < shells/2; i++ {
2024-01-21 04:16:48 -06:00
g.shellArray[i] = true
2024-01-20 22:06:56 -06:00
}
for i := shells / 2; i < shells; i++ {
2024-01-21 04:16:48 -06:00
g.shellArray[i] = false
2024-01-20 22:06:56 -06:00
}
2024-01-21 04:16:48 -06:00
g.shells = g.shellArray[:shells]
ShuffleSlice(&g.rng, g.shells)
2024-01-28 12:42:42 -06:00
g.game++
2024-01-21 04:16:48 -06:00
g.turn = 0
g.prev = nil
2024-01-21 00:13:23 -06:00
g.NextTurn()
}
2024-01-28 12:42:42 -06:00
// NextTurn advances the turn but not the match or round state.
func (g *Match) NextTurn() {
2024-01-21 04:16:48 -06:00
g.turn++
g.damage = 1
g.reveal = false
2024-01-21 00:13:23 -06:00
cur := g.CurrentPlayer()
2024-01-21 04:16:48 -06:00
skip := cur.cuffs == CuffedSkip
cur.cuffs = cur.cuffs.NextState()
2024-01-21 01:22:44 -06:00
if skip {
g.NextTurn()
}
}
2024-01-28 12:42:42 -06:00
// CurrentPlayer gets the current player.
func (g *Match) CurrentPlayer() *Player {
2024-01-21 04:16:48 -06:00
return &g.players[g.turn%2]
}
// Opponent returns the player who is not the current player.
2024-01-28 12:42:42 -06:00
func (g *Match) Opponent() *Player {
2024-01-21 04:16:48 -06:00
return &g.players[g.turn%2^1]
}
// Apply uses an item by index for the current player.
2024-01-28 12:42:42 -06:00
// 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.
2024-01-28 12:42:42 -06:00
func (g *Match) Apply(id player.ID, item int) error {
cur := g.CurrentPlayer()
2024-01-21 04:16:48 -06:00
if cur.id != id {
return ErrWrongTurn
}
2024-01-21 04:16:48 -06:00
if item < 0 || item >= len(cur.items) {
return errors.New("item index out of bounds")
2024-01-20 23:02:15 -06:00
}
2024-01-21 04:16:48 -06:00
if cur.items[item].Apply(g) {
cur.items[item] = ItemNone
2024-01-20 23:02:15 -06:00
}
return nil
}
2024-01-28 12:42:42 -06:00
// popShell removes a shell from the shotgun.
// This may cause the shotgun to be empty, but does not start the next game
// if so.
2024-01-28 12:42:42 -06:00
func (g *Match) popShell() bool {
2024-01-21 04:16:48 -06:00
g.prev = &g.shells[0]
g.shells = g.shells[1:]
return *g.prev
}
2024-01-21 00:33:39 -06:00
// Peek returns the current turn's shell if it is revealed for the player with
// the given ID, or nil otherwise.
2024-01-28 12:42:42 -06:00
func (g *Match) Peek(id player.ID) *bool {
2024-01-21 04:16:48 -06:00
if len(g.shells) == 0 || id != g.CurrentPlayer().id || !g.reveal {
2024-01-21 00:33:39 -06:00
return nil
}
2024-01-21 04:16:48 -06:00
return &g.shells[0]
2024-01-21 00:33:39 -06:00
}
// Empty returns whether the shotgun is empty.
2024-01-28 12:42:42 -06:00
func (g *Match) Empty() bool {
2024-01-21 04:16:48 -06:00
return len(g.shells) == 0
2024-01-20 22:06:56 -06:00
}
2024-01-20 23:52:58 -06:00
2024-01-28 12:42:42 -06:00
// Winner returns the player who won the current round, or nil if the round is
// not over.
func (g *Match) Winner() *Player {
2024-01-21 04:16:48 -06:00
if g.players[0].hp <= 0 {
return &g.players[1]
2024-01-20 23:52:58 -06:00
}
2024-01-21 04:16:48 -06:00
if g.players[1].hp <= 0 {
return &g.players[0]
2024-01-20 23:52:58 -06:00
}
return nil
}
// Shoot fires the shotgun, at the opponent if self is false and at the current
2024-01-28 12:42:42 -06:00
// player if self is true. Afterward, the round or game may be over.
// Returns ErrWrongTurn if id does not correspond to the current player.
2024-01-28 12:42:42 -06:00
func (g *Match) Shoot(id player.ID, self bool) error {
cur := g.CurrentPlayer()
2024-01-21 04:16:48 -06:00
if cur.id != id {
return ErrWrongTurn
}
2024-01-20 23:52:58 -06:00
target := g.Opponent()
if self {
target = g.CurrentPlayer()
}
2024-01-28 12:42:42 -06:00
live := g.popShell()
2024-01-20 23:52:58 -06:00
if live {
2024-01-21 04:16:48 -06:00
target.hp -= g.damage
2024-01-21 00:13:23 -06:00
g.NextTurn()
2024-01-20 23:52:58 -06:00
} else if !self {
2024-01-21 00:13:23 -06:00
g.NextTurn()
2024-01-20 23:52:58 -06:00
}
return nil
2024-01-20 23:52:58 -06:00
}
2024-01-28 12:42:42 -06:00
// 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 {
2024-01-23 20:45:00 -06:00
switch id {
case g.players[0].id:
g.players[0].hp = 0
case g.players[1].id:
g.players[1].hp = 0
default:
return false
}
2024-01-28 12:42:42 -06:00
g.round = 3
2024-01-23 20:45:00 -06:00
return true
}
2024-01-28 12:42:42 -06:00
// DTO returns the current match state as viewed by the given player.
func (g *Match) DTO(id player.ID) serve.Game {
2024-01-21 00:56:45 -06:00
return serve.Game{
Players: [2]serve.Player{
2024-01-21 04:16:48 -06:00
g.players[0].DTO(),
g.players[1].DTO(),
2024-01-21 00:56:45 -06:00
},
2024-01-21 04:16:48 -06:00
Round: g.round,
Damage: g.damage,
2024-01-21 00:56:45 -06:00
Shell: g.Peek(id),
2024-01-21 04:16:48 -06:00
Previous: g.prev,
2024-01-21 00:56:45 -06:00
}
}
// ShellCounts returns the number of live and blank shells.
2024-01-28 12:42:42 -06:00
func (g *Match) ShellCounts() serve.ShellCounts {
var counts serve.ShellCounts
2024-01-21 04:16:48 -06:00
for _, s := range g.shells {
if s {
counts.Live++
} else {
counts.Blank++
}
}
return counts
}
var ErrWrongTurn = errors.New("not your turn")