// 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 2-5 items delivered to each player. // The challenger always moves first at the start of each game. package game import ( "errors" "math/rand/v2" "time" "git.sunturtle.xyz/studio/shotgun/player" "git.sunturtle.xyz/studio/shotgun/serve" ) type Match struct { // players are the players in the match. The first element is the dealer // and the second is the challenger. players [2]playerState // 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{ players: [2]playerState{ {id: dealer}, {id: challenger}, }, } g.NextRound() return g } // NextRound starts the next round of a match. func (g *Match) NextRound() { g.hp = int8(rand.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 := rand.IntN(4) + 2 g.players[0].startGame(items) g.players[1].startGame(items) shells := rand.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] shuffle(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.resetTurn() cur := g.CurrentPlayer() skip := cur.cuffs == cuffedSkip cur.cuffs = cur.cuffs.nextState() if skip { g.nextTurn() } } // resetTurn performs start-of-turn model resets. func (g *Match) resetTurn() { g.damage = 1 g.reveal = false } // CurrentPlayer gets the current player. func (g *Match) CurrentPlayer() *playerState { return &g.players[g.turn&1] } // Opponent returns the player who is not the current player. func (g *Match) Opponent() *playerState { 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() *playerState { 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() *playerState { 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() } else { g.resetTurn() } 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 } // Expire makes the current player concede. func (g *Match) Expire() { g.Concede(g.CurrentPlayer().id) } // DTO returns the current match state as viewed by the given player and with // the given deadline on the current player's next move. func (g *Match) DTO(id player.ID, deadline time.Time) 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.MatchWinner() { 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, Deadline: deadline.UnixMilli(), 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") )