548 lines
12 KiB
Go
548 lines
12 KiB
Go
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
|
|
State GameState `json:"state"`
|
|
Winner *Player `json:"winner"`
|
|
PendingChoice *PendingChoice `json:"pending_choice,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"`
|
|
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.Winner = nil
|
|
g.PendingChoice = 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")
|
|
}
|
|
|
|
// 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
|
|
return nil
|
|
}
|
|
|
|
if res.ExtraTurn {
|
|
// current player goes again — don't advance
|
|
return nil
|
|
}
|
|
if res.ChoicePrompt != "" && len(res.ChoiceOptions) > 0 {
|
|
g.PendingChoice = &PendingChoice{
|
|
PlayerID: p.ID,
|
|
Prompt: res.ChoicePrompt,
|
|
Options: res.ChoiceOptions,
|
|
}
|
|
return nil
|
|
}
|
|
g.nextTurn()
|
|
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")
|
|
}
|
|
|
|
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
|
|
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 {
|
|
g.PenaltyCards = extraRes.PenaltyCards
|
|
g.SkipNext = extraRes.SkipNext
|
|
if extraRes.ActiveSuit != "" {
|
|
g.ActiveSuit = extraRes.ActiveSuit
|
|
}
|
|
if extraRes.SetReverseDir {
|
|
g.Direction = 1
|
|
if extraRes.ReverseDir {
|
|
g.Direction = -1
|
|
}
|
|
}
|
|
if extraRes.InstantWin {
|
|
g.State = Ended
|
|
g.Winner = p
|
|
g.PendingChoice = nil
|
|
return nil
|
|
}
|
|
if len(p.Hand) == 0 {
|
|
g.State = Ended
|
|
g.Winner = p
|
|
g.PendingChoice = nil
|
|
return nil
|
|
}
|
|
if extraRes.ChoicePrompt != "" && len(extraRes.ChoiceOptions) > 0 {
|
|
g.PendingChoice = &PendingChoice{
|
|
PlayerID: p.ID,
|
|
Prompt: extraRes.ChoicePrompt,
|
|
Options: extraRes.ChoiceOptions,
|
|
}
|
|
return nil
|
|
}
|
|
if extraRes.ExtraTurn {
|
|
g.PendingChoice = nil
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
if len(p.Hand) == 0 {
|
|
g.State = Ended
|
|
g.Winner = p
|
|
g.PendingChoice = nil
|
|
return nil
|
|
}
|
|
|
|
g.PendingChoice = nil
|
|
if res.ExtraTurn {
|
|
return nil
|
|
}
|
|
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
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (g *Game) nextTurn() {
|
|
n := len(g.Players)
|
|
g.CurrentPlayer = ((g.CurrentPlayer+g.Direction)%n + n) % n
|
|
}
|
|
|
|
// 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))
|
|
for i, pl := range g.Players {
|
|
counts[i] = len(pl.Hand)
|
|
}
|
|
|
|
return GameScriptState{
|
|
ActiveSuit: g.ActiveSuit,
|
|
PenaltyCards: g.PenaltyCards,
|
|
SkipNext: g.SkipNext,
|
|
ChosenSuit: chosenSuit,
|
|
Hand: handCopy,
|
|
PlayerCount: len(g.Players),
|
|
HandCounts: counts,
|
|
Direction: g.Direction,
|
|
}
|
|
}
|