package engine import ( "errors" "fmt" "log" "math/rand" "sort" "sync" "time" ) type Suit string const ( Hearts Suit = "hearts" Diamonds Suit = "diamonds" Spades Suit = "spades" Clubs Suit = "clubs" ) type Value string const ( Seven Value = "7" Eight Value = "8" Nine Value = "9" Ten Value = "10" Under Value = "under" Upper Value = "upper" King Value = "king" Ace Value = "ace" ) type Card struct { Suit Suit `json:"suit"` Value Value `json:"value"` } type Player struct { ID string `json:"id"` Name string `json:"name"` Hand []Card `json:"hand"` } type GameState string const ( Waiting GameState = "waiting" Playing GameState = "playing" Ended GameState = "ended" ) // ActiveRule describes a custom rule applied to this room. type ActiveRule struct { Description string `json:"description"` ProposedBy string `json:"proposed_by"` } type Game struct { ID string `json:"id"` Players []*Player `json:"players"` Deck []Card `json:"-"` DiscardPile []Card `json:"discard_pile"` CurrentPlayer int `json:"current_player_index"` ActiveSuit Suit `json:"active_suit"` PenaltyCards int `json:"penalty_cards"` SkipNext bool `json:"skip_next"` Direction int `json:"direction"` // 1 = clockwise, -1 = counter-clockwise TurnNumber int `json:"turn_number"` RoundNumber int `json:"round_number"` LastActionType string `json:"last_action_type,omitempty"` LastActorIndex int `json:"last_actor_index,omitempty"` State GameState `json:"state"` Winner *Player `json:"winner"` PendingChoice *PendingChoice `json:"pending_choice,omitempty"` ChoiceQueue []PendingChoice `json:"choice_queue,omitempty"` ActiveRules []ActiveRule `json:"active_rules"` Mu sync.RWMutex `json:"-"` script *ScriptRuntime } // PendingChoice is a UI prompt the current player must resolve before turn can continue. type PendingChoice struct { PlayerID string `json:"player_id"` PlayerIndex int `json:"player_index"` Prompt string `json:"prompt"` Options []string `json:"options"` } func NewGame(id, scriptSrc string) (*Game, error) { if scriptSrc == "" { scriptSrc = DefaultScript } rt, err := NewScriptRuntime(scriptSrc) if err != nil { return nil, err } return &Game{ ID: id, State: Waiting, Deck: createDeck(), script: rt, }, nil } func (g *Game) ScriptSource() string { if g.script == nil { return DefaultScript } return g.script.Source() } func createDeck() []Card { suits := []Suit{Hearts, Diamonds, Spades, Clubs} values := []Value{Seven, Eight, Nine, Ten, Under, Upper, King, Ace} deck := make([]Card, 0, 32) for _, s := range suits { for _, v := range values { deck = append(deck, Card{Suit: s, Value: v}) } } r := rand.New(rand.NewSource(time.Now().UnixNano())) r.Shuffle(len(deck), func(i, j int) { deck[i], deck[j] = deck[j], deck[i] }) return deck } func (g *Game) AddPlayer(id, name string) error { g.Mu.Lock() defer g.Mu.Unlock() if g.State != Waiting { return errors.New("game already started") } if len(g.Players) >= 4 { return errors.New("game full") } for _, p := range g.Players { if p.ID == id { return nil } } g.Players = append(g.Players, &Player{ID: id, Name: name, Hand: []Card{}}) return nil } func (g *Game) Start() error { g.Mu.Lock() defer g.Mu.Unlock() if len(g.Players) < 2 { return errors.New("not enough players") } if g.State == Playing { return errors.New("already started") } g.Deck = createDeck() for _, p := range g.Players { p.Hand = []Card{} } g.DiscardPile = nil g.PenaltyCards = 0 g.SkipNext = false g.Direction = 1 g.TurnNumber = 1 g.RoundNumber = 1 g.LastActionType = "start" g.LastActorIndex = 0 g.Winner = nil g.PendingChoice = nil g.ChoiceQueue = nil for i := 0; i < 4; i++ { for _, p := range g.Players { c, err := g.drawCard() if err != nil { return err } p.Hand = append(p.Hand, c) } } firstCard, err := g.drawCard() if err != nil { return err } for firstCard.Value == Upper { g.Deck = append(g.Deck, firstCard) r := rand.New(rand.NewSource(time.Now().UnixNano())) r.Shuffle(len(g.Deck), func(i, j int) { g.Deck[i], g.Deck[j] = g.Deck[j], g.Deck[i] }) firstCard, err = g.drawCard() if err != nil { return err } } g.DiscardPile = []Card{firstCard} g.ActiveSuit = firstCard.Suit g.State = Playing g.CurrentPlayer = 0 state := g.buildState(g.Players[0], firstCard.Suit) res, err := g.script.OnPlayed([]Card{firstCard}, state) if err != nil { log.Printf("script onPlayed error on first card: %v", err) } else { g.PenaltyCards = res.PenaltyCards g.SkipNext = res.SkipNext g.ActiveSuit = res.ActiveSuit } return nil } func (g *Game) drawCard() (Card, error) { if len(g.Deck) == 0 && len(g.DiscardPile) <= 1 { return Card{}, errors.New("no cards available to draw") } if len(g.Deck) == 0 { top := g.DiscardPile[len(g.DiscardPile)-1] g.Deck = g.DiscardPile[:len(g.DiscardPile)-1] g.DiscardPile = []Card{top} r := rand.New(rand.NewSource(time.Now().UnixNano())) r.Shuffle(len(g.Deck), func(i, j int) { g.Deck[i], g.Deck[j] = g.Deck[j], g.Deck[i] }) } if len(g.Deck) == 0 { return Card{}, errors.New("no cards available to draw") } card := g.Deck[len(g.Deck)-1] g.Deck = g.Deck[:len(g.Deck)-1] return card, nil } // PlayCards plays a single selected card from the current player's hand. // chosenSuit is used when an Upper is played. func (g *Game) PlayCards(playerID string, cardIndices []int, chosenSuit Suit) error { g.Mu.Lock() defer g.Mu.Unlock() if g.State != Playing { return errors.New("game not in playing state") } if len(cardIndices) == 0 { return errors.New("no cards selected") } if len(cardIndices) != 1 { return errors.New("only one card can be played per turn") } if g.PendingChoice != nil { return errors.New("resolve pending choice first") } p := g.Players[g.CurrentPlayer] if p.ID != playerID { return errors.New("not your turn") } actorIndex := g.CurrentPlayer // Validate all indices. seen := make(map[int]bool) for _, idx := range cardIndices { if idx < 0 || idx >= len(p.Hand) { return fmt.Errorf("invalid card index %d", idx) } if seen[idx] { return fmt.Errorf("duplicate card index %d", idx) } seen[idx] = true } // Sort descending so removal by index is safe. sorted := make([]int, len(cardIndices)) copy(sorted, cardIndices) sort.Sort(sort.Reverse(sort.IntSlice(sorted))) // Collect the cards in the exact order selected by the player. // This allows scripts to reason about sequence-sensitive combos. cards := make([]Card, len(cardIndices)) for i, idx := range cardIndices { cards[i] = p.Hand[idx] } top := g.DiscardPile[len(g.DiscardPile)-1] state := g.buildState(p, chosenSuit) // Ask script whether the play is legal. ok, err := g.script.CanPlay(cards, top, state) if err != nil { log.Printf("script canPlay error: %v", err) ok = false } if !ok { return errors.New("cannot play these cards") } // Remove cards from hand (highest index first to preserve lower indices). for _, idx := range sorted { p.Hand = append(p.Hand[:idx], p.Hand[idx+1:]...) } // Place cards on discard pile. for _, c := range cards { g.DiscardPile = append(g.DiscardPile, c) } // Apply side-effects. res, err := g.script.OnPlayed(cards, state) if err != nil { log.Printf("script onPlayed error: %v", err) } else { if err := g.applyScriptStateMutation(res); err != nil { return err } if res.InstantWin { g.State = Ended g.Winner = p return nil } } if len(p.Hand) == 0 { g.State = Ended g.Winner = p g.LastActionType = "win" g.LastActorIndex = actorIndex return nil } if res.ExtraTurn { // current player goes again — don't advance return nil } if g.PendingChoice != nil { return nil } g.LastActionType = "play" g.LastActorIndex = actorIndex g.nextTurn() g.LastActionType = "draw" g.LastActorIndex = actorIndex return nil } // PlayCard is a convenience wrapper for playing a single card. func (g *Game) PlayCard(playerID string, cardIdx int, chosenSuit Suit) error { return g.PlayCards(playerID, []int{cardIdx}, chosenSuit) } func (g *Game) Draw(playerID string) error { g.Mu.Lock() defer g.Mu.Unlock() if g.State != Playing { return errors.New("game not in playing state") } if g.PendingChoice != nil { return errors.New("resolve pending choice first") } p := g.Players[g.CurrentPlayer] if p.ID != playerID { return errors.New("not your turn") } if g.PenaltyCards > 0 { for i := 0; i < g.PenaltyCards; i++ { c, err := g.drawCard() if err != nil { return err } p.Hand = append(p.Hand, c) } g.PenaltyCards = 0 } else if g.SkipNext { g.SkipNext = false } else { c, err := g.drawCard() if err != nil { return err } p.Hand = append(p.Hand, c) } g.nextTurn() return nil } // ApplyChoice resolves a pending post-play choice for the current player. func (g *Game) ApplyChoice(playerID, choice string) error { g.Mu.Lock() defer g.Mu.Unlock() if g.State != Playing { return errors.New("game not in playing state") } if g.PendingChoice == nil { return errors.New("no pending choice") } if g.PendingChoice.PlayerID != playerID { return errors.New("not your choice") } chooserIndex := g.PendingChoice.PlayerIndex valid := false for _, o := range g.PendingChoice.Options { if o == choice { valid = true break } } if !valid { return errors.New("invalid choice option") } p := g.Players[g.CurrentPlayer] state := g.buildState(p, g.ActiveSuit) res, err := g.script.OnChoice(choice, state) if err != nil { log.Printf("script onChoice error: %v", err) } if err := g.applyScriptStateMutation(res); err != nil { return err } if res.InstantWin { g.State = Ended g.Winner = p g.PendingChoice = nil g.ChoiceQueue = nil g.LastActionType = "win" g.LastActorIndex = chooserIndex return nil } if res.SetDiscardIdx { if res.DiscardIdx < 0 || res.DiscardIdx >= len(p.Hand) { return errors.New("invalid discard index") } extra := p.Hand[res.DiscardIdx] p.Hand = append(p.Hand[:res.DiscardIdx], p.Hand[res.DiscardIdx+1:]...) g.DiscardPile = append(g.DiscardPile, extra) extraState := g.buildState(p, g.ActiveSuit) extraRes, err := g.script.OnPlayed([]Card{extra}, extraState) if err != nil { log.Printf("script onPlayed error on extra discard: %v", err) } else { if err := g.applyScriptStateMutation(extraRes); err != nil { return err } if extraRes.InstantWin { g.State = Ended g.Winner = p g.PendingChoice = nil g.ChoiceQueue = nil g.LastActionType = "win" g.LastActorIndex = chooserIndex return nil } if len(p.Hand) == 0 { g.State = Ended g.Winner = p g.PendingChoice = nil return nil } if g.PendingChoice != nil { return nil } if extraRes.ExtraTurn { g.PendingChoice = nil return nil } } } if len(p.Hand) == 0 { g.State = Ended g.Winner = p g.PendingChoice = nil g.ChoiceQueue = nil g.LastActionType = "win" g.LastActorIndex = chooserIndex return nil } g.PendingChoice = nil if len(g.ChoiceQueue) > 0 { next := g.ChoiceQueue[0] g.ChoiceQueue = g.ChoiceQueue[1:] g.PendingChoice = &next g.LastActionType = "choice_pending" g.LastActorIndex = chooserIndex return nil } if res.ExtraTurn { g.LastActionType = "choice" g.LastActorIndex = chooserIndex return nil } g.LastActionType = "choice" g.LastActorIndex = chooserIndex g.nextTurn() return nil } func (g *Game) applyScriptStateMutation(res OnPlayedResult) error { g.PenaltyCards = res.PenaltyCards g.SkipNext = res.SkipNext if res.ActiveSuit != "" { g.ActiveSuit = res.ActiveSuit } if res.SetReverseDir { g.Direction = 1 if res.ReverseDir { g.Direction = -1 } } if res.SetPlayerHands { if len(res.PlayerHands) != len(g.Players) { return errors.New("playerHands length mismatch") } for i := range g.Players { h := make([]Card, len(res.PlayerHands[i])) copy(h, res.PlayerHands[i]) g.Players[i].Hand = h } } if res.SetDiscardPile { if len(res.DiscardPile) == 0 { return errors.New("discard pile cannot be empty") } pile := make([]Card, len(res.DiscardPile)) copy(pile, res.DiscardPile) g.DiscardPile = pile } if res.SetDeck { deck := make([]Card, len(res.Deck)) copy(deck, res.Deck) g.Deck = deck } if res.SetCurrentPlayer { if res.CurrentPlayer < 0 || res.CurrentPlayer >= len(g.Players) { return errors.New("invalid currentPlayerIndex") } g.CurrentPlayer = res.CurrentPlayer } if res.ChoicePrompt != "" && len(res.ChoiceOptions) > 0 { target := g.CurrentPlayer if res.SetChoiceTargetPlayer { if res.ChoiceTargetPlayer < 0 || res.ChoiceTargetPlayer >= len(g.Players) { return errors.New("invalid choiceTargetPlayerIndex") } target = res.ChoiceTargetPlayer } ch := PendingChoice{ PlayerID: g.Players[target].ID, PlayerIndex: target, Prompt: res.ChoicePrompt, Options: append([]string(nil), res.ChoiceOptions...), } if g.PendingChoice == nil { g.PendingChoice = &ch } else { g.ChoiceQueue = append(g.ChoiceQueue, ch) } } return nil } func (g *Game) nextTurn() { n := len(g.Players) prev := g.CurrentPlayer g.CurrentPlayer = ((g.CurrentPlayer+g.Direction)%n + n) % n g.TurnNumber++ if g.CurrentPlayer == 0 && prev != 0 { g.RoundNumber++ } } // buildState constructs the GameScriptState for the current moment. // chosenSuit is the suit the player selected (for Upper plays). func (g *Game) buildState(p *Player, chosenSuit Suit) GameScriptState { handCopy := make([]Card, len(p.Hand)) copy(handCopy, p.Hand) counts := make([]int, len(g.Players)) hands := make([][]Card, len(g.Players)) for i, pl := range g.Players { counts[i] = len(pl.Hand) h := make([]Card, len(pl.Hand)) copy(h, pl.Hand) hands[i] = h } discardCopy := make([]Card, len(g.DiscardPile)) copy(discardCopy, g.DiscardPile) deckCopy := make([]Card, len(g.Deck)) copy(deckCopy, g.Deck) return GameScriptState{ ActiveSuit: g.ActiveSuit, PenaltyCards: g.PenaltyCards, SkipNext: g.SkipNext, ChosenSuit: chosenSuit, Hand: handCopy, PlayerHands: hands, DiscardPile: discardCopy, Deck: deckCopy, CurrentPlayerIndex: g.CurrentPlayer, PlayerCount: len(g.Players), HandCounts: counts, Direction: g.Direction, TurnNumber: g.TurnNumber, RoundNumber: g.RoundNumber, LastActionType: g.LastActionType, LastActorIndex: g.LastActorIndex, PendingChoices: func() []ScriptPendingChoice { out := make([]ScriptPendingChoice, 0, 1+len(g.ChoiceQueue)) if g.PendingChoice != nil { out = append(out, ScriptPendingChoice{ TargetPlayerIndex: g.PendingChoice.PlayerIndex, Prompt: g.PendingChoice.Prompt, Options: append([]string(nil), g.PendingChoice.Options...), }) } for _, q := range g.ChoiceQueue { out = append(out, ScriptPendingChoice{ TargetPlayerIndex: q.PlayerIndex, Prompt: q.Prompt, Options: append([]string(nil), q.Options...), }) } return out }(), } }