prsi/internal/engine/game.go
Pavel Flegr ff5d6ab91d asd
2026-05-07 19:10:23 +02:00

623 lines
15 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
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
}
return GameScriptState{
ActiveSuit: g.ActiveSuit,
PenaltyCards: g.PenaltyCards,
SkipNext: g.SkipNext,
ChosenSuit: chosenSuit,
Hand: handCopy,
PlayerHands: hands,
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
}(),
}
}