When the server's DEBUG env var is on, expose a card catalog endpoint and a collapsible client panel that drops any pet or food straight into your deck (free, off-turn). Gated server-side so it is inert in normal play.
635 lines
17 KiB
Go
635 lines
17 KiB
Go
package game
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"math/big"
|
|
"slices"
|
|
)
|
|
|
|
// Tunable rules. The engine supports any player count >= 2; MinPlayers /
|
|
// MaxPlayers gate when a lobby can start (2 for now, more later).
|
|
const (
|
|
MaxRounds = 6
|
|
CoinsPerRound = 3
|
|
ShopRowSize = 4
|
|
MaxPets = 5
|
|
TradeInCount = 3
|
|
MinPlayers = 2
|
|
MaxPlayers = 2
|
|
)
|
|
|
|
// Phase is the game's top-level state.
|
|
type Phase string
|
|
|
|
const (
|
|
PhaseLobby Phase = "lobby" // waiting for players
|
|
PhaseShop Phase = "shop" // players take turns spending coins
|
|
PhaseCleanup Phase = "cleanup" // forced discard down to MaxPets pets
|
|
PhaseArrange Phase = "arrange" // players order their decks for battle
|
|
PhaseBattle Phase = "battle" // battle resolved; players review the log
|
|
PhaseGameOver Phase = "gameover" // all rounds played
|
|
)
|
|
|
|
// Player holds everything about one seat. All fields are exported so a Game
|
|
// serializes to JSON for persistence.
|
|
type Player struct {
|
|
ID string `json:"id"`
|
|
Token string `json:"token"` // secret; never sent in views
|
|
Name string `json:"name"`
|
|
Seat int `json:"seat"`
|
|
Coins int `json:"coins"`
|
|
Deck []Card `json:"deck"`
|
|
Trophies int `json:"trophies"`
|
|
Ready bool `json:"ready"` // arrange submitted / battle acknowledged
|
|
Connected bool `json:"connected"`
|
|
// TripledThisRound records whether the player used the Triple (trade-in)
|
|
// action during the current round's shop (Bison's Battle Prep).
|
|
TripledThisRound bool `json:"tripledThisRound"`
|
|
}
|
|
|
|
// PetCount counts pet cards in the player's deck.
|
|
func (p *Player) PetCount() int {
|
|
n := 0
|
|
for _, c := range p.Deck {
|
|
if c.IsPet() {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
func (p *Player) cardIndex(cardID string) int {
|
|
return slices.IndexFunc(p.Deck, func(c Card) bool { return c.ID == cardID })
|
|
}
|
|
|
|
// PendingTrade is an in-progress trade-in: the trading player has paid and
|
|
// must now pick one of two revealed cards from the next tier's deck.
|
|
type PendingTrade struct {
|
|
PlayerID string `json:"playerId"`
|
|
Tier int `json:"tier"` // 1-based tier the options came from
|
|
Options [2]Card `json:"options"`
|
|
}
|
|
|
|
// Game is the complete authoritative state. It is a pure state machine: no
|
|
// goroutines, no clocks, no I/O. Callers are responsible for locking.
|
|
type Game struct {
|
|
ID string `json:"id"`
|
|
Code string `json:"code"`
|
|
Phase Phase `json:"phase"`
|
|
Round int `json:"round"` // 1-based
|
|
Players []*Player `json:"players"`
|
|
ShopDecks [][]Card `json:"shopDecks"` // index 0 = tier 1
|
|
ShopRow []Card `json:"shopRow"` // empty ID = empty slot
|
|
Turn int `json:"turn"` // seat with the current shop turn
|
|
// PrioritySeat holds the priority token: that seat shops first each round
|
|
// and wins simultaneity races in battle. Assigned randomly at game start;
|
|
// a battle winner hands it to the loser, a loser keeps it, a draw leaves
|
|
// it put.
|
|
PrioritySeat int `json:"prioritySeat"`
|
|
Pending *PendingTrade `json:"pending,omitempty"`
|
|
Battle *BattleResult `json:"battle,omitempty"` // most recent battle
|
|
NextCardID int `json:"nextCardId"`
|
|
WinnerSeat int `json:"winnerSeat"` // set at gameover; -1 = tie
|
|
|
|
// RollDie overrides the rock die (faces 0,0,1,1,2,2) for tests. Nil
|
|
// (including after loading from storage) means a fair random roll.
|
|
RollDie func() int `json:"-"`
|
|
}
|
|
|
|
// rollRockDie rolls one rock die: 0, 1, or 2 with equal probability.
|
|
func (g *Game) rollRockDie() int {
|
|
if g.RollDie != nil {
|
|
return g.RollDie()
|
|
}
|
|
return randInt(3)
|
|
}
|
|
|
|
var (
|
|
ErrNotYourTurn = errors.New("not your turn")
|
|
ErrWrongPhase = errors.New("action not allowed in this phase")
|
|
ErrNoCoins = errors.New("no coins remaining")
|
|
ErrInvalidAction = errors.New("invalid action")
|
|
)
|
|
|
|
func randomID(n int) string {
|
|
b := make([]byte, n)
|
|
if _, err := rand.Read(b); err != nil {
|
|
panic(err)
|
|
}
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
func randomCode() string {
|
|
const letters = "ABCDEFGHJKMNPQRSTUVWXYZ23456789" // no easily-confused chars
|
|
code := make([]byte, 5)
|
|
for i := range code {
|
|
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(letters))))
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
code[i] = letters[n.Int64()]
|
|
}
|
|
return string(code)
|
|
}
|
|
|
|
func randInt(n int) int {
|
|
v, err := rand.Int(rand.Reader, big.NewInt(int64(n)))
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return int(v.Int64())
|
|
}
|
|
|
|
func shuffle[T any](s []T) {
|
|
for i := len(s) - 1; i > 0; i-- {
|
|
j := randInt(i + 1)
|
|
s[i], s[j] = s[j], s[i]
|
|
}
|
|
}
|
|
|
|
// New creates a game in the lobby phase with its shop decks built and
|
|
// shuffled. All later "randomness" is just drawing from these decks, so the
|
|
// state is fully deterministic (and serializable) after this point.
|
|
func New() *Game {
|
|
g := &Game{
|
|
ID: randomID(16),
|
|
Code: randomCode(),
|
|
Phase: PhaseLobby,
|
|
WinnerSeat: -1,
|
|
}
|
|
g.buildShopDecks()
|
|
for i := range g.ShopDecks {
|
|
shuffle(g.ShopDecks[i])
|
|
}
|
|
return g
|
|
}
|
|
|
|
// AddPlayer seats a new player during the lobby phase and returns them (with
|
|
// their secret token). The game starts automatically once full.
|
|
func (g *Game) AddPlayer(name string) (*Player, error) {
|
|
if g.Phase != PhaseLobby {
|
|
return nil, fmt.Errorf("%w: game already started", ErrWrongPhase)
|
|
}
|
|
if len(g.Players) >= MaxPlayers {
|
|
return nil, errors.New("game is full")
|
|
}
|
|
if name == "" {
|
|
name = fmt.Sprintf("Player %d", len(g.Players)+1)
|
|
}
|
|
p := &Player{
|
|
ID: randomID(8),
|
|
Token: randomID(16),
|
|
Name: name,
|
|
Seat: len(g.Players),
|
|
}
|
|
g.Players = append(g.Players, p)
|
|
if len(g.Players) == MaxPlayers {
|
|
g.start()
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
// PlayerByID returns the player, or nil.
|
|
func (g *Game) PlayerByID(id string) *Player {
|
|
for _, p := range g.Players {
|
|
if p.ID == id {
|
|
return p
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (g *Game) start() {
|
|
g.Round = 1
|
|
// The priority token starts with a random seat.
|
|
g.PrioritySeat = randInt(len(g.Players))
|
|
g.startShopRound()
|
|
}
|
|
|
|
// startShopRound resets coins, deals the shop row from this round's tier
|
|
// deck, and rotates the starting player.
|
|
func (g *Game) startShopRound() {
|
|
g.Phase = PhaseShop
|
|
g.Pending = nil
|
|
for _, p := range g.Players {
|
|
p.Coins = CoinsPerRound
|
|
p.Ready = false
|
|
p.TripledThisRound = false
|
|
}
|
|
g.ShopRow = make([]Card, ShopRowSize)
|
|
for i := range g.ShopRow {
|
|
g.ShopRow[i] = g.drawFromTier(g.Round)
|
|
}
|
|
// The priority-token holder shops first.
|
|
g.Turn = g.PrioritySeat
|
|
}
|
|
|
|
// drawFromTier pops the top card of the given tier's deck (1-based tier).
|
|
// Returns a zero Card if the deck is empty.
|
|
func (g *Game) drawFromTier(tier int) Card {
|
|
deck := g.ShopDecks[tier-1]
|
|
if len(deck) == 0 {
|
|
return Card{}
|
|
}
|
|
top := deck[0]
|
|
g.ShopDecks[tier-1] = deck[1:]
|
|
return top
|
|
}
|
|
|
|
// requireShopTurn validates that playerID may act in the shop right now.
|
|
func (g *Game) requireShopTurn(playerID string) (*Player, error) {
|
|
if g.Phase != PhaseShop {
|
|
return nil, ErrWrongPhase
|
|
}
|
|
p := g.PlayerByID(playerID)
|
|
if p == nil {
|
|
return nil, errors.New("unknown player")
|
|
}
|
|
if g.Players[g.Turn].ID != playerID {
|
|
return nil, ErrNotYourTurn
|
|
}
|
|
if g.Pending != nil {
|
|
return nil, fmt.Errorf("%w: finish your trade first", ErrInvalidAction)
|
|
}
|
|
if p.Coins <= 0 {
|
|
return nil, ErrNoCoins
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
// Buy spends one coin to take the card at rowIdx into the player's deck.
|
|
// The slot refills from the current round's tier deck.
|
|
func (g *Game) Buy(playerID string, rowIdx int) error {
|
|
p, err := g.requireShopTurn(playerID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if rowIdx < 0 || rowIdx >= len(g.ShopRow) || g.ShopRow[rowIdx].ID == "" {
|
|
return fmt.Errorf("%w: no card in that shop slot", ErrInvalidAction)
|
|
}
|
|
p.Coins--
|
|
bought := g.ShopRow[rowIdx]
|
|
p.Deck = append(p.Deck, bought)
|
|
g.ShopRow[rowIdx] = g.drawFromTier(g.Round)
|
|
g.applyShopTrigger(p, bought, TriggerBuy)
|
|
g.advanceShopTurn()
|
|
return nil
|
|
}
|
|
|
|
// Sell spends one coin to sell any number (>=1) of the player's cards: each
|
|
// becomes an apple, and Sell effects on the sold cards fire.
|
|
func (g *Game) Sell(playerID string, cardIDs []string) error {
|
|
p, err := g.requireShopTurn(playerID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(cardIDs) == 0 {
|
|
return fmt.Errorf("%w: choose at least one card to sell", ErrInvalidAction)
|
|
}
|
|
if err := g.sellCards(p, cardIDs); err != nil {
|
|
return err
|
|
}
|
|
p.Coins--
|
|
g.advanceShopTurn()
|
|
return nil
|
|
}
|
|
|
|
// applyShopTrigger fires a shop-time trigger (buy/sell/triple/battle prep)
|
|
// on one card. Battle-time actions on the same trigger (Monkey's
|
|
// applesInPlay) are ignored here and handled by the battle resolver.
|
|
func (g *Game) applyShopTrigger(p *Player, c Card, trigger EffectTrigger) {
|
|
for _, e := range c.Effects {
|
|
if e.Trigger != trigger || g.Round < e.MinRound {
|
|
continue
|
|
}
|
|
if e.Condition == ConditionTripled && !p.TripledThisRound {
|
|
continue
|
|
}
|
|
switch e.Action {
|
|
case ActionGainApple:
|
|
for range e.count() {
|
|
p.Deck = append(p.Deck, g.newApple())
|
|
}
|
|
case ActionRefreshGold:
|
|
p.Coins = min(p.Coins+e.count(), CoinsPerRound)
|
|
case ActionDoubleApples:
|
|
apples := 0
|
|
for _, dc := range p.Deck {
|
|
if dc.Food == FoodApple {
|
|
apples++
|
|
}
|
|
}
|
|
for range apples {
|
|
p.Deck = append(p.Deck, g.newApple())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// sellCards removes the given cards from p's deck, adds one apple per
|
|
// removed card, and fires the sold cards' Sell effects. It validates before
|
|
// mutating.
|
|
func (g *Game) sellCards(p *Player, cardIDs []string) error {
|
|
if hasDuplicates(cardIDs) {
|
|
return fmt.Errorf("%w: duplicate card", ErrInvalidAction)
|
|
}
|
|
for _, id := range cardIDs {
|
|
if p.cardIndex(id) < 0 {
|
|
return fmt.Errorf("%w: card not in your deck", ErrInvalidAction)
|
|
}
|
|
}
|
|
sold := make([]Card, 0, len(cardIDs))
|
|
for _, id := range cardIDs {
|
|
idx := p.cardIndex(id)
|
|
sold = append(sold, p.Deck[idx])
|
|
p.Deck = slices.Delete(p.Deck, idx, idx+1)
|
|
}
|
|
for _, c := range sold {
|
|
p.Deck = append(p.Deck, g.newApple())
|
|
g.applyShopTrigger(p, c, TriggerSell)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// TradeStart spends one coin and three same-suit pets from the player's deck
|
|
// to reveal the top two cards of the next tier's deck. The player must then
|
|
// call TradeChoose before anything else happens.
|
|
func (g *Game) TradeStart(playerID string, cardIDs []string) error {
|
|
p, err := g.requireShopTurn(playerID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if g.Round >= MaxRounds {
|
|
return fmt.Errorf("%w: no higher tier to trade into", ErrInvalidAction)
|
|
}
|
|
if len(cardIDs) != TradeInCount || hasDuplicates(cardIDs) {
|
|
return fmt.Errorf("%w: trade in exactly %d cards", ErrInvalidAction, TradeInCount)
|
|
}
|
|
var suit Suit
|
|
for i, id := range cardIDs {
|
|
idx := p.cardIndex(id)
|
|
if idx < 0 {
|
|
return fmt.Errorf("%w: card not in your deck", ErrInvalidAction)
|
|
}
|
|
c := p.Deck[idx]
|
|
if !c.IsPet() {
|
|
return fmt.Errorf("%w: only pets have suits", ErrInvalidAction)
|
|
}
|
|
if i == 0 {
|
|
suit = c.Suit
|
|
} else if c.Suit != suit {
|
|
return fmt.Errorf("%w: cards must share a suit", ErrInvalidAction)
|
|
}
|
|
}
|
|
nextTier := g.Round + 1
|
|
if len(g.ShopDecks[nextTier-1]) < 2 {
|
|
return fmt.Errorf("%w: next tier deck is exhausted", ErrInvalidAction)
|
|
}
|
|
// Validated; commit.
|
|
traded := make([]Card, 0, TradeInCount)
|
|
for _, id := range cardIDs {
|
|
idx := p.cardIndex(id)
|
|
traded = append(traded, p.Deck[idx])
|
|
p.Deck = slices.Delete(p.Deck, idx, idx+1)
|
|
}
|
|
p.Coins--
|
|
p.TripledThisRound = true
|
|
g.Pending = &PendingTrade{
|
|
PlayerID: playerID,
|
|
Tier: nextTier,
|
|
Options: [2]Card{g.drawFromTier(nextTier), g.drawFromTier(nextTier)},
|
|
}
|
|
// Triple effects fire on the traded-in cards themselves (e.g. Fish).
|
|
for _, c := range traded {
|
|
g.applyShopTrigger(p, c, TriggerTriple)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// TradeChoose resolves a pending trade: pick (0 or 1) joins the player's
|
|
// deck, the other goes to the bottom of its tier deck.
|
|
func (g *Game) TradeChoose(playerID string, pick int) error {
|
|
if g.Phase != PhaseShop || g.Pending == nil || g.Pending.PlayerID != playerID {
|
|
return fmt.Errorf("%w: no trade waiting on you", ErrInvalidAction)
|
|
}
|
|
if pick != 0 && pick != 1 {
|
|
return fmt.Errorf("%w: pick 0 or 1", ErrInvalidAction)
|
|
}
|
|
p := g.PlayerByID(playerID)
|
|
chosen, other := g.Pending.Options[pick], g.Pending.Options[1-pick]
|
|
p.Deck = append(p.Deck, chosen)
|
|
tierIdx := g.Pending.Tier - 1
|
|
g.ShopDecks[tierIdx] = append(g.ShopDecks[tierIdx], other)
|
|
g.Pending = nil
|
|
// Pets obtained via the Triple action trigger their Buy effects.
|
|
g.applyShopTrigger(p, chosen, TriggerBuy)
|
|
g.advanceShopTurn()
|
|
return nil
|
|
}
|
|
|
|
// DebugGrant drops any card straight into a player's deck during the shop,
|
|
// free and off-turn — a testing aid gated behind the server's DEBUG flag, not
|
|
// a normal action. No buy effects fire.
|
|
func (g *Game) DebugGrant(playerID, name string) error {
|
|
if g.Phase != PhaseShop && g.Phase != PhaseCleanup {
|
|
return ErrWrongPhase
|
|
}
|
|
p := g.PlayerByID(playerID)
|
|
if p == nil {
|
|
return errors.New("unknown player")
|
|
}
|
|
c, ok := g.cardByName(name)
|
|
if !ok {
|
|
return fmt.Errorf("%w: no card named %q", ErrInvalidAction, name)
|
|
}
|
|
p.Deck = append(p.Deck, c)
|
|
return nil
|
|
}
|
|
|
|
// Pass forfeits the player's remaining coins and ends their shopping.
|
|
func (g *Game) Pass(playerID string) error {
|
|
p, err := g.requireShopTurn(playerID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
p.Coins = 0
|
|
g.advanceShopTurn()
|
|
return nil
|
|
}
|
|
|
|
// advanceShopTurn hands the turn to the next player who still has coins, or
|
|
// moves the game onward when everyone is spent.
|
|
func (g *Game) advanceShopTurn() {
|
|
for i := 1; i <= len(g.Players); i++ {
|
|
seat := (g.Turn + i) % len(g.Players)
|
|
if g.Players[seat].Coins > 0 {
|
|
g.Turn = seat
|
|
return
|
|
}
|
|
}
|
|
g.endShop()
|
|
}
|
|
|
|
// endShop moves to forced discard if anyone is over the pet limit, otherwise
|
|
// straight to arranging.
|
|
func (g *Game) endShop() {
|
|
over := false
|
|
for _, p := range g.Players {
|
|
p.Ready = p.PetCount() <= MaxPets
|
|
if !p.Ready {
|
|
over = true
|
|
}
|
|
}
|
|
if over {
|
|
g.Phase = PhaseCleanup
|
|
return
|
|
}
|
|
g.beginArrange()
|
|
}
|
|
|
|
// CleanupSell performs the forced end-of-shop sale: the player must sell
|
|
// exactly their excess pets (each becomes an apple; Sell effects fire).
|
|
func (g *Game) CleanupSell(playerID string, cardIDs []string) error {
|
|
if g.Phase != PhaseCleanup {
|
|
return ErrWrongPhase
|
|
}
|
|
p := g.PlayerByID(playerID)
|
|
if p == nil {
|
|
return errors.New("unknown player")
|
|
}
|
|
excess := p.PetCount() - MaxPets
|
|
if excess <= 0 {
|
|
return fmt.Errorf("%w: you are not over the pet limit", ErrInvalidAction)
|
|
}
|
|
if len(cardIDs) != excess {
|
|
return fmt.Errorf("%w: sell exactly %d pets", ErrInvalidAction, excess)
|
|
}
|
|
for _, id := range cardIDs {
|
|
idx := p.cardIndex(id)
|
|
if idx < 0 || !p.Deck[idx].IsPet() {
|
|
return fmt.Errorf("%w: pick pets from your deck", ErrInvalidAction)
|
|
}
|
|
}
|
|
if err := g.sellCards(p, cardIDs); err != nil {
|
|
return err
|
|
}
|
|
p.Ready = true
|
|
if g.allReady() {
|
|
g.beginArrange()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (g *Game) allReady() bool {
|
|
for _, p := range g.Players {
|
|
if !p.Ready {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (g *Game) beginArrange() {
|
|
g.Phase = PhaseArrange
|
|
for _, p := range g.Players {
|
|
p.Ready = false
|
|
// Battle Prep effects fire now, before players order their cards
|
|
// (e.g. Giraffe hands out apples that can go into the deck order).
|
|
for _, c := range slices.Clone(p.Deck) {
|
|
g.applyShopTrigger(p, c, TriggerBattlePrep)
|
|
}
|
|
}
|
|
}
|
|
|
|
// SubmitOrder records the player's battle ordering (a permutation of their
|
|
// deck's card IDs, top of deck first). When everyone has submitted, the
|
|
// battle resolves.
|
|
func (g *Game) SubmitOrder(playerID string, orderedIDs []string) error {
|
|
if g.Phase != PhaseArrange {
|
|
return ErrWrongPhase
|
|
}
|
|
p := g.PlayerByID(playerID)
|
|
if p == nil {
|
|
return errors.New("unknown player")
|
|
}
|
|
if p.Ready {
|
|
return fmt.Errorf("%w: order already submitted", ErrInvalidAction)
|
|
}
|
|
if len(orderedIDs) != len(p.Deck) || hasDuplicates(orderedIDs) {
|
|
return fmt.Errorf("%w: order must include each of your cards exactly once", ErrInvalidAction)
|
|
}
|
|
ordered := make([]Card, 0, len(p.Deck))
|
|
for _, id := range orderedIDs {
|
|
idx := p.cardIndex(id)
|
|
if idx < 0 {
|
|
return fmt.Errorf("%w: card not in your deck", ErrInvalidAction)
|
|
}
|
|
ordered = append(ordered, p.Deck[idx])
|
|
}
|
|
p.Deck = ordered
|
|
p.Ready = true
|
|
if g.allReady() {
|
|
g.resolveBattle()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AcknowledgeBattle marks the player done reviewing the battle. When all
|
|
// players acknowledge, the next round starts (or the game ends).
|
|
func (g *Game) AcknowledgeBattle(playerID string) error {
|
|
if g.Phase != PhaseBattle {
|
|
return ErrWrongPhase
|
|
}
|
|
p := g.PlayerByID(playerID)
|
|
if p == nil {
|
|
return errors.New("unknown player")
|
|
}
|
|
p.Ready = true
|
|
if !g.allReady() {
|
|
return nil
|
|
}
|
|
// Temporary cards (apples, bees) expire once their battle has happened.
|
|
for _, pl := range g.Players {
|
|
pl.Deck = slices.DeleteFunc(pl.Deck, func(c Card) bool { return c.Temporary })
|
|
}
|
|
if g.Round >= MaxRounds {
|
|
g.finish()
|
|
return nil
|
|
}
|
|
g.Round++
|
|
g.startShopRound()
|
|
return nil
|
|
}
|
|
|
|
func (g *Game) finish() {
|
|
g.Phase = PhaseGameOver
|
|
best, bestSeat, tie := -1, -1, false
|
|
for _, p := range g.Players {
|
|
switch {
|
|
case p.Trophies > best:
|
|
best, bestSeat, tie = p.Trophies, p.Seat, false
|
|
case p.Trophies == best:
|
|
tie = true
|
|
}
|
|
}
|
|
if tie {
|
|
g.WinnerSeat = -1
|
|
} else {
|
|
g.WinnerSeat = bestSeat
|
|
}
|
|
}
|
|
|
|
func hasDuplicates(ids []string) bool {
|
|
seen := make(map[string]struct{}, len(ids))
|
|
for _, id := range ids {
|
|
if _, ok := seen[id]; ok {
|
|
return true
|
|
}
|
|
seen[id] = struct{}{}
|
|
}
|
|
return false
|
|
}
|