271 lines
6.9 KiB
Go
271 lines
6.9 KiB
Go
// 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 (
|
|
"errors"
|
|
|
|
"git.sunturtle.xyz/studio/shotgun/player"
|
|
"git.sunturtle.xyz/studio/shotgun/serve"
|
|
)
|
|
|
|
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 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 int8
|
|
// turn is the turn number. When even, it is the dealer's turn.
|
|
turn int8
|
|
// 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
|
|
// action is a description of the previous action.
|
|
action action
|
|
|
|
// shellArray is the backing storage for shells and prev.
|
|
shellArray [8]bool
|
|
}
|
|
|
|
// 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.NextRound()
|
|
return g
|
|
}
|
|
|
|
// 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.NextGame()
|
|
}
|
|
|
|
// 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)
|
|
shells := g.rng.Intn(6) + 2
|
|
for i := 0; i < shells/2; i++ {
|
|
g.shellArray[i] = true
|
|
}
|
|
for i := shells / 2; i < shells; i++ {
|
|
g.shellArray[i] = false
|
|
}
|
|
g.shells = g.shellArray[:shells]
|
|
ShuffleSlice(&g.rng, g.shells)
|
|
g.turn = 0
|
|
g.prev = nil
|
|
g.NextTurn()
|
|
}
|
|
|
|
// NextTurn advances the turn but not the match or round state.
|
|
func (g *Match) NextTurn() {
|
|
g.turn++
|
|
g.damage = 1
|
|
g.reveal = false
|
|
cur := g.CurrentPlayer()
|
|
skip := cur.cuffs == CuffedSkip
|
|
cur.cuffs = cur.cuffs.NextState()
|
|
if skip {
|
|
g.NextTurn()
|
|
}
|
|
}
|
|
|
|
// CurrentPlayer gets the current player.
|
|
func (g *Match) CurrentPlayer() *Player {
|
|
return &g.players[g.turn&1]
|
|
}
|
|
|
|
// Opponent returns the player who is not the current player.
|
|
func (g *Match) Opponent() *Player {
|
|
return &g.players[g.turn&1^1]
|
|
}
|
|
|
|
// Apply uses an item by index for the current player.
|
|
// If this causes the current game to end (a beer discharges the last shell),
|
|
// returns ErrGameEnded but does not start the next game.
|
|
// Returns ErrWrongTurn if id does not correspond to the current player.
|
|
func (g *Match) Apply(id player.ID, item int) error {
|
|
cur := g.CurrentPlayer()
|
|
if cur.id != id {
|
|
return ErrWrongTurn
|
|
}
|
|
if item < 0 || item >= len(cur.items) {
|
|
return errors.New("item index out of bounds")
|
|
}
|
|
if cur.items[item].Apply(g) {
|
|
cur.items[item] = ItemNone
|
|
}
|
|
if g.Empty() {
|
|
return ErrGameEnded
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// 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 *Match) popShell() bool {
|
|
g.prev = &g.shells[0]
|
|
g.shells = g.shells[1:]
|
|
return *g.prev
|
|
}
|
|
|
|
// Peek returns the current turn's shell if it is revealed for the player with
|
|
// the given ID, or nil otherwise.
|
|
func (g *Match) Peek(id player.ID) *bool {
|
|
if len(g.shells) == 0 || id != g.CurrentPlayer().id || !g.reveal {
|
|
return nil
|
|
}
|
|
return &g.shells[0]
|
|
}
|
|
|
|
// Empty returns whether the shotgun is empty.
|
|
func (g *Match) Empty() bool {
|
|
return len(g.shells) == 0
|
|
}
|
|
|
|
// RoundWinner returns the player who won the current round, or nil if the
|
|
// round is not over.
|
|
func (g *Match) RoundWinner() *Player {
|
|
if g.players[0].hp <= 0 {
|
|
return &g.players[1]
|
|
}
|
|
if g.players[1].hp <= 0 {
|
|
return &g.players[0]
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MatchWinner returns the player who has won the match, or nil if the match
|
|
// is not over.
|
|
func (g *Match) MatchWinner() *Player {
|
|
if g.round < 3 {
|
|
return nil
|
|
}
|
|
return g.RoundWinner()
|
|
}
|
|
|
|
// Shoot fires the shotgun, at the opponent if self is false and at the current
|
|
// player if self is true. Advances the turn if appropriate.
|
|
// Returns ErrRoundEnded if this causes the round to end.
|
|
// Returns ErrGameEnded if this causes the game but not the round to end.
|
|
// Returns ErrWrongTurn if id does not correspond to the current player.
|
|
func (g *Match) Shoot(id player.ID, self bool) error {
|
|
cur := g.CurrentPlayer()
|
|
if cur.id != id {
|
|
return ErrWrongTurn
|
|
}
|
|
target := g.Opponent()
|
|
if self {
|
|
target = g.CurrentPlayer()
|
|
}
|
|
live := g.popShell()
|
|
g.action = actShoot
|
|
if live {
|
|
target.hp -= g.damage
|
|
if target.hp <= 0 {
|
|
target.hp = 0 // in case it goes negative
|
|
g.action = actChallengerWins
|
|
// If the target is the challenger, the match is over as well.
|
|
if target == &g.players[1] {
|
|
g.action = actDealerWins
|
|
g.round = 3
|
|
}
|
|
return ErrRoundEnded
|
|
}
|
|
}
|
|
if g.Empty() {
|
|
g.action = actGameEnd
|
|
return ErrGameEnded
|
|
}
|
|
if !self || live {
|
|
g.NextTurn()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Concede sets the player with the given ID to zero health and ends the match.
|
|
// The returned error is ErrRoundEnded if id corresponds to either player and
|
|
// ErrWrongTurn otherwise.
|
|
func (g *Match) Concede(id player.ID) error {
|
|
switch id {
|
|
case g.players[0].id:
|
|
g.action = actDealerConcedes
|
|
g.players[0].hp = 0
|
|
case g.players[1].id:
|
|
g.action = actChallengerConcedes
|
|
g.players[1].hp = 0
|
|
default:
|
|
return ErrWrongTurn
|
|
}
|
|
g.round = 3
|
|
return ErrRoundEnded
|
|
}
|
|
|
|
// DTO returns the current match state as viewed by the given player.
|
|
func (g *Match) DTO(id player.ID) serve.Game {
|
|
var live, blank int
|
|
if g.action.gameStart() {
|
|
live = len(g.shells) / 2
|
|
blank = len(g.shells) - live
|
|
}
|
|
w := serve.NoWinner
|
|
switch g.RoundWinner() {
|
|
case &g.players[0]:
|
|
w = serve.DealerWins
|
|
case &g.players[1]:
|
|
w = serve.ChallengerWins
|
|
}
|
|
return serve.Game{
|
|
Players: [2]serve.Player{
|
|
g.players[0].DTO(),
|
|
g.players[1].DTO(),
|
|
},
|
|
Action: g.action.String(),
|
|
Winner: w,
|
|
Round: g.round,
|
|
Dealer: g.CurrentPlayer() == &g.players[0],
|
|
Damage: g.damage,
|
|
Shell: g.Peek(id),
|
|
Previous: g.prev,
|
|
Live: live,
|
|
Blank: blank,
|
|
}
|
|
}
|
|
|
|
var (
|
|
ErrWrongTurn = errors.New("not your turn")
|
|
ErrGameEnded = errors.New("the shotgun is empty")
|
|
ErrRoundEnded = errors.New("someone h*ckin died")
|
|
)
|