This commit is contained in:
Pavel Flegr 2026-05-07 17:29:58 +02:00
commit b3f02ea15e
10 changed files with 3567 additions and 0 deletions

548
internal/engine/game.go Normal file
View file

@ -0,0 +1,548 @@
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,
}
}

View file

@ -0,0 +1,273 @@
package engine
import "testing"
func TestStartReinitializesEndedGameWithSamePlayers(t *testing.T) {
g, err := NewGame("room-1", DefaultScript)
if err != nil {
t.Fatalf("NewGame failed: %v", err)
}
if err := g.AddPlayer("p1", "Alice"); err != nil {
t.Fatalf("AddPlayer p1 failed: %v", err)
}
if err := g.AddPlayer("p2", "Bob"); err != nil {
t.Fatalf("AddPlayer p2 failed: %v", err)
}
if err := g.Start(); err != nil {
t.Fatalf("first Start failed: %v", err)
}
if g.State != Playing {
t.Fatalf("expected state=%q, got %q", Playing, g.State)
}
g.Mu.Lock()
g.State = Ended
g.Winner = g.Players[0]
g.Mu.Unlock()
if err := g.Start(); err != nil {
t.Fatalf("restart Start failed: %v", err)
}
if g.State != Playing {
t.Fatalf("expected restarted state=%q, got %q", Playing, g.State)
}
if g.Winner != nil {
t.Fatalf("winner should be reset on restart")
}
if len(g.Players) != 2 {
t.Fatalf("expected players to be preserved, got %d", len(g.Players))
}
}
func TestStartFailsWithNotEnoughPlayers(t *testing.T) {
g, err := NewGame("room-2", DefaultScript)
if err != nil {
t.Fatalf("NewGame failed: %v", err)
}
if err := g.AddPlayer("p1", "Alice"); err != nil {
t.Fatalf("AddPlayer failed: %v", err)
}
if err := g.Start(); err == nil {
t.Fatalf("expected Start to fail with one player")
}
}
func TestDrawWhenDeckAndDiscardAreExhaustedDoesNotPanic(t *testing.T) {
g, err := NewGame("room-3", DefaultScript)
if err != nil {
t.Fatalf("NewGame failed: %v", err)
}
g.Players = []*Player{{ID: "p1", Name: "Alice"}, {ID: "p2", Name: "Bob"}}
g.State = Playing
g.CurrentPlayer = 0
g.Deck = nil
g.DiscardPile = []Card{{Suit: Hearts, Value: Seven}}
defer func() {
if r := recover(); r != nil {
t.Fatalf("Draw panicked with exhausted deck/discard: %v", r)
}
}()
err = g.Draw("p1")
if err == nil {
t.Fatalf("expected Draw to return an error when no cards are available")
}
}
func TestPlayCardsRejectsMultipleSelection(t *testing.T) {
g, err := NewGame("room-5", DefaultScript)
if err != nil {
t.Fatalf("NewGame failed: %v", err)
}
g.Players = []*Player{{ID: "p1", Name: "Alice"}, {ID: "p2", Name: "Bob"}}
g.State = Playing
g.CurrentPlayer = 0
g.ActiveSuit = Hearts
g.DiscardPile = []Card{{Suit: Hearts, Value: Nine}}
g.Players[0].Hand = []Card{{Suit: Hearts, Value: King}, {Suit: Hearts, Value: Ace}}
if err := g.PlayCards("p1", []int{0, 1}, Hearts); err == nil {
t.Fatalf("expected multi-card play to be rejected")
}
}
func TestPendingChoiceBlocksTurnUntilChoiceApplied(t *testing.T) {
script := `
function canPlay(cards, topCard, state) {
return cards.length === 1;
}
function onPlayed(cards, state) {
return {
penaltyCards: state.penaltyCards,
skipNext: state.skipNext,
activeSuit: cards[0].suit,
choicePrompt: "Pick mode",
choiceOptions: ["keep", "reverse"]
};
}
function onChoice(choice, state) {
return {
penaltyCards: state.penaltyCards,
skipNext: state.skipNext,
activeSuit: state.activeSuit,
reverseDir: choice === "reverse"
};
}
`
g, err := NewGame("room-6", script)
if err != nil {
t.Fatalf("NewGame failed: %v", err)
}
g.Players = []*Player{{ID: "p1", Name: "Alice"}, {ID: "p2", Name: "Bob"}}
g.State = Playing
g.CurrentPlayer = 0
g.ActiveSuit = Hearts
g.Direction = 1
g.DiscardPile = []Card{{Suit: Hearts, Value: Seven}}
g.Players[0].Hand = []Card{{Suit: Hearts, Value: Nine}, {Suit: Clubs, Value: Ten}}
g.Players[1].Hand = []Card{{Suit: Clubs, Value: Nine}}
if err := g.PlayCards("p1", []int{0}, Hearts); err != nil {
t.Fatalf("PlayCards failed: %v", err)
}
if g.PendingChoice == nil {
t.Fatalf("expected pending choice")
}
if g.CurrentPlayer != 0 {
t.Fatalf("turn should not advance before choice")
}
if err := g.Draw("p1"); err == nil {
t.Fatalf("draw should be blocked while choice is pending")
}
if err := g.ApplyChoice("p1", "reverse"); err != nil {
t.Fatalf("ApplyChoice failed: %v", err)
}
if g.PendingChoice != nil {
t.Fatalf("pending choice should be cleared")
}
if g.Direction != -1 {
t.Fatalf("expected reverse direction after choice")
}
}
func TestOnChoiceCanDiscardExtraCardByIndex(t *testing.T) {
script := `
function canPlay(cards, topCard, state) {
return cards.length === 1;
}
function onPlayed(cards, state) {
var c = cards[0];
var out = {
penaltyCards: state.penaltyCards,
skipNext: state.skipNext,
activeSuit: c.suit
};
if (c.value === "king") {
out.choicePrompt = "Discard extra?";
out.choiceOptions = ["yes", "no"];
}
return out;
}
function onChoice(choice, state) {
var out = {
penaltyCards: state.penaltyCards,
skipNext: state.skipNext,
activeSuit: state.activeSuit
};
if (choice === "yes") out.discardIndex = 0;
return out;
}
`
g, err := NewGame("room-7", script)
if err != nil {
t.Fatalf("NewGame failed: %v", err)
}
g.Players = []*Player{{ID: "p1", Name: "Alice"}, {ID: "p2", Name: "Bob"}}
g.State = Playing
g.CurrentPlayer = 0
g.ActiveSuit = Hearts
g.Direction = 1
g.DiscardPile = []Card{{Suit: Hearts, Value: Nine}}
g.Players[0].Hand = []Card{{Suit: Hearts, Value: King}, {Suit: Clubs, Value: Ten}}
g.Players[1].Hand = []Card{{Suit: Spades, Value: Seven}}
if err := g.PlayCards("p1", []int{0}, Hearts); err != nil {
t.Fatalf("PlayCards failed: %v", err)
}
if g.PendingChoice == nil {
t.Fatalf("expected pending choice after king")
}
if len(g.Players[0].Hand) != 1 {
t.Fatalf("expected one remaining card before choice, got %d", len(g.Players[0].Hand))
}
if err := g.ApplyChoice("p1", "yes"); err != nil {
t.Fatalf("ApplyChoice failed: %v", err)
}
if len(g.Players[0].Hand) != 0 {
t.Fatalf("expected extra card to be discarded, hand=%d", len(g.Players[0].Hand))
}
if g.State != Ended || g.Winner == nil || g.Winner.ID != "p1" {
t.Fatalf("expected player to win after discarding last card")
}
}
func TestOnPlayedCanReplaceHandsAndDiscardPile(t *testing.T) {
script := `
function canPlay(cards, topCard, state) {
return cards.length === 1;
}
function onPlayed(cards, state) {
var me = state.hand.slice();
if (me.length > 0) me = me.slice(1);
return {
penaltyCards: 0,
skipNext: false,
activeSuit: "clubs",
playerHands: [me, []],
playedPile: [{ suit: "clubs", value: "10" }],
currentPlayerIndex: 1
};
}
`
g, err := NewGame("room-8", script)
if err != nil {
t.Fatalf("NewGame failed: %v", err)
}
g.Players = []*Player{{ID: "p1", Name: "Alice"}, {ID: "p2", Name: "Bob"}}
g.State = Playing
g.CurrentPlayer = 0
g.ActiveSuit = Hearts
g.DiscardPile = []Card{{Suit: Hearts, Value: Nine}}
g.Deck = []Card{{Suit: Spades, Value: Seven}}
g.Players[0].Hand = []Card{{Suit: Hearts, Value: Ace}, {Suit: Clubs, Value: King}}
g.Players[1].Hand = []Card{{Suit: Diamonds, Value: Ten}}
if err := g.PlayCards("p1", []int{0}, Hearts); err != nil {
t.Fatalf("PlayCards failed: %v", err)
}
if g.ActiveSuit != Clubs {
t.Fatalf("expected active suit clubs, got %q", g.ActiveSuit)
}
if len(g.DiscardPile) != 1 || g.DiscardPile[0].Suit != Clubs || g.DiscardPile[0].Value != Ten {
t.Fatalf("discard pile was not replaced by script")
}
if len(g.Players[1].Hand) != 0 {
t.Fatalf("expected player 2 hand replaced to empty")
}
if g.CurrentPlayer != 1 {
t.Fatalf("expected current player override to 1, got %d", g.CurrentPlayer)
}
}

408
internal/engine/script.go Normal file
View file

@ -0,0 +1,408 @@
package engine
import (
"fmt"
"log"
"strconv"
"github.com/dop251/goja"
)
// DefaultScript implements the standard Prší rules.
//
// Script contract — every rules script MUST export canPlay/onPlayed.
// onChoice is optional; if missing, default no-op behaviour is used.
//
// canPlay(cards, topCard, state) → bool
// onPlayed(cards, state) → OnPlayedResult
// onChoice(choice, state) → OnPlayedResult
//
// cards array of { suit, value } objects the player wants to play.
//
// Engine enforces exactly one card, so cards.length is always 1.
//
// topCard { suit, value } — current top of the discard pile
// state see GameScriptState below
//
// onPlayed return object (all fields optional — omitted fields keep their current value):
//
// penaltyCards number — new accumulated draw penalty
// skipNext bool — new skip flag
// activeSuit string — new active suit
// instantWin bool — current player wins immediately
// extraTurn bool — current player gets an additional turn
// reverseDir bool — toggle turn-order direction (false = clockwise, true = counter-clockwise)
// choicePrompt string — optional UI prompt shown to current player
// choiceOptions string[] — allowed answer options for that prompt
// discardIndex number — optional index of one extra card to discard from current hand
// playerHands Card[][] — optional full replacement of all players' hands
// discardPile Card[] — optional full replacement of played/discard pile
// playedPile Card[] — alias for discardPile
// deck Card[] — optional full replacement of draw deck
// currentPlayerIndex number — optional current player override
const DefaultScript = `
// ── Standard Prší rules ───────────────────────────────────────────────
function canPlay(cards, topCard, state) {
if (cards.length !== 1) return false;
var card = cards[0];
if (state.penaltyCards > 0) return card.value === "7";
if (state.skipNext) return card.value === "ace";
if (card.value === "upper") return true;
return card.suit === state.activeSuit || card.value === topCard.value;
}
function onPlayed(cards, state) {
var card = cards[0];
var result = {
penaltyCards: 0,
skipNext: false,
activeSuit: card.suit
};
if (card.value === "7") {
result.penaltyCards = state.penaltyCards + 2;
} else if (card.value === "ace") {
result.skipNext = true;
result.activeSuit = card.suit;
} else if (card.value === "upper") {
result.activeSuit = state.chosenSuit || card.suit;
}
return result;
}
function onChoice(choice, state) {
return {
penaltyCards: state.penaltyCards,
skipNext: state.skipNext,
activeSuit: state.activeSuit
};
}
`
// GameScriptState is the rich context object passed into every JS function call.
type GameScriptState struct {
ActiveSuit Suit
PenaltyCards int
SkipNext bool
ChosenSuit Suit
Hand []Card // acting player's full hand (including cards being played)
PlayerCount int
HandCounts []int // cards held by each player, in seat order
Direction int // 1 = clockwise, -1 = counter-clockwise
}
// OnPlayedResult carries every mutation onPlayed() can request.
// All fields are optional; the engine only applies what the script explicitly returns.
type OnPlayedResult struct {
PenaltyCards int
SkipNext bool
ActiveSuit Suit
InstantWin bool // current player wins immediately
ExtraTurn bool // current player takes another turn
SetReverseDir bool // whether to apply the ReverseDir value
ReverseDir bool // new direction: false=CW, true=CCW (only used when SetReverseDir=true)
ChoicePrompt string
ChoiceOptions []string
SetDiscardIdx bool
DiscardIdx int
SetPlayerHands bool
PlayerHands [][]Card
SetDiscardPile bool
DiscardPile []Card
SetDeck bool
Deck []Card
SetCurrentPlayer bool
CurrentPlayer int
}
// ScriptRuntime holds a compiled goja VM. NOT goroutine-safe.
type ScriptRuntime struct {
vm *goja.Runtime
source string
}
func NewScriptRuntime(source string) (*ScriptRuntime, error) {
vm := goja.New()
if _, err := vm.RunString(source); err != nil {
return nil, fmt.Errorf("script compile error: %w", err)
}
for _, fn := range []string{"canPlay", "onPlayed"} {
if _, ok := goja.AssertFunction(vm.Get(fn)); !ok {
return nil, fmt.Errorf("script is missing required function %q", fn)
}
}
return &ScriptRuntime{vm: vm, source: source}, nil
}
func (sr *ScriptRuntime) Source() string { return sr.source }
// CanPlay calls JS canPlay(cards, topCard, state) → bool.
func (sr *ScriptRuntime) CanPlay(cards []Card, topCard Card, state GameScriptState) (bool, error) {
fn, ok := goja.AssertFunction(sr.vm.Get("canPlay"))
if !ok {
return false, fmt.Errorf("canPlay not found")
}
result, err := fn(goja.Undefined(), sr.cardsVal(cards), sr.cardVal(topCard), sr.stateVal(state))
if err != nil {
return false, err
}
return result.ToBoolean(), nil
}
// OnPlayed calls JS onPlayed(cards, state) and returns structured mutations.
func (sr *ScriptRuntime) OnPlayed(cards []Card, state GameScriptState) (OnPlayedResult, error) {
defaults := OnPlayedResult{
PenaltyCards: state.PenaltyCards,
SkipNext: state.SkipNext,
ActiveSuit: state.ActiveSuit,
}
fn, ok := goja.AssertFunction(sr.vm.Get("onPlayed"))
if !ok {
return defaults, fmt.Errorf("onPlayed not found")
}
res, err := fn(goja.Undefined(), sr.cardsVal(cards), sr.stateVal(state))
if err != nil {
return defaults, err
}
return sr.parseResult(res, defaults), nil
}
// OnChoice calls JS onChoice(choice, state) and returns structured mutations.
func (sr *ScriptRuntime) OnChoice(choice string, state GameScriptState) (OnPlayedResult, error) {
defaults := OnPlayedResult{
PenaltyCards: state.PenaltyCards,
SkipNext: state.SkipNext,
ActiveSuit: state.ActiveSuit,
}
fn, ok := goja.AssertFunction(sr.vm.Get("onChoice"))
if !ok {
return defaults, nil
}
res, err := fn(goja.Undefined(), sr.vm.ToValue(choice), sr.stateVal(state))
if err != nil {
return defaults, err
}
return sr.parseResult(res, defaults), nil
}
// ── Value builders ────────────────────────────────────────────────────────────
func (sr *ScriptRuntime) cardVal(c Card) goja.Value {
return sr.vm.ToValue(map[string]string{"suit": string(c.Suit), "value": string(c.Value)})
}
func (sr *ScriptRuntime) cardsVal(cards []Card) goja.Value {
objs := make([]interface{}, len(cards))
for i, c := range cards {
objs[i] = map[string]string{"suit": string(c.Suit), "value": string(c.Value)}
}
return sr.vm.ToValue(objs)
}
func (sr *ScriptRuntime) stateVal(s GameScriptState) goja.Value {
hand := make([]interface{}, len(s.Hand))
for i, c := range s.Hand {
hand[i] = map[string]string{"suit": string(c.Suit), "value": string(c.Value)}
}
counts := make([]interface{}, len(s.HandCounts))
for i, n := range s.HandCounts {
counts[i] = n
}
return sr.vm.ToValue(map[string]interface{}{
"activeSuit": string(s.ActiveSuit),
"penaltyCards": s.PenaltyCards,
"skipNext": s.SkipNext,
"chosenSuit": string(s.ChosenSuit),
"hand": hand,
"playerCount": s.PlayerCount,
"handCounts": counts,
"direction": s.Direction,
})
}
// ── Result parser — uses goja's own object API, not Export(). ─────────────────
// This is robust regardless of how the LLM wrote the return statement.
func (sr *ScriptRuntime) parseResult(v goja.Value, defaults OnPlayedResult) OnPlayedResult {
if goja.IsNull(v) || goja.IsUndefined(v) {
return defaults
}
obj := v.ToObject(sr.vm)
if obj == nil {
return defaults
}
out := defaults
if p := objGet(sr.vm, obj, "penaltyCards"); p != nil {
out.PenaltyCards = int(p.ToInteger())
}
if s := objGet(sr.vm, obj, "skipNext"); s != nil {
out.SkipNext = s.ToBoolean()
}
if a := objGet(sr.vm, obj, "activeSuit"); a != nil {
if str := a.String(); str != "" && str != "undefined" {
out.ActiveSuit = Suit(str)
}
}
if w := objGet(sr.vm, obj, "instantWin"); w != nil {
out.InstantWin = w.ToBoolean()
}
if e := objGet(sr.vm, obj, "extraTurn"); e != nil {
out.ExtraTurn = e.ToBoolean()
}
if rd := objGet(sr.vm, obj, "reverseDir"); rd != nil {
out.ReverseDir = rd.ToBoolean()
out.SetReverseDir = true
}
if cp := objGet(sr.vm, obj, "choicePrompt"); cp != nil {
out.ChoicePrompt = cp.String()
}
if co := objGet(sr.vm, obj, "choiceOptions"); co != nil {
if arr := co.ToObject(sr.vm); arr != nil {
var opts []string
for i := 0; ; i++ {
v := arr.Get(fmt.Sprintf("%d", i))
if v == nil || goja.IsUndefined(v) || goja.IsNull(v) {
break
}
opts = append(opts, v.String())
}
out.ChoiceOptions = opts
}
}
if di := objGet(sr.vm, obj, "discardIndex"); di != nil {
out.DiscardIdx = int(di.ToInteger())
out.SetDiscardIdx = true
}
if ph := objGet(sr.vm, obj, "playerHands"); ph != nil {
if hands, ok := sr.parseHands(ph); ok {
out.PlayerHands = hands
out.SetPlayerHands = true
}
}
if dp := objGet(sr.vm, obj, "discardPile"); dp != nil {
if pile, ok := sr.parseCards(dp); ok {
out.DiscardPile = pile
out.SetDiscardPile = true
}
}
if pp := objGet(sr.vm, obj, "playedPile"); pp != nil {
if pile, ok := sr.parseCards(pp); ok {
out.DiscardPile = pile
out.SetDiscardPile = true
}
}
if dk := objGet(sr.vm, obj, "deck"); dk != nil {
if deck, ok := sr.parseCards(dk); ok {
out.Deck = deck
out.SetDeck = true
}
}
if cp := objGet(sr.vm, obj, "currentPlayerIndex"); cp != nil {
out.CurrentPlayer = int(cp.ToInteger())
out.SetCurrentPlayer = true
}
return out
}
// objGet returns the named property of obj, or nil if it is absent/undefined/null.
func objGet(vm *goja.Runtime, obj *goja.Object, key string) goja.Value {
v := obj.Get(key)
if v == nil || goja.IsUndefined(v) || goja.IsNull(v) {
return nil
}
_ = vm // kept for potential future use
_ = log.Writer
return v
}
func (sr *ScriptRuntime) parseHands(v goja.Value) ([][]Card, bool) {
obj := v.ToObject(sr.vm)
if obj == nil {
return nil, false
}
length := obj.Get("length")
if length == nil || goja.IsUndefined(length) || goja.IsNull(length) {
return nil, false
}
n := int(length.ToInteger())
if n < 0 {
return nil, false
}
hands := make([][]Card, n)
for i := 0; i < n; i++ {
entry := obj.Get(strconv.Itoa(i))
cards, ok := sr.parseCards(entry)
if !ok {
return nil, false
}
hands[i] = cards
}
return hands, true
}
func (sr *ScriptRuntime) parseCards(v goja.Value) ([]Card, bool) {
obj := v.ToObject(sr.vm)
if obj == nil {
return nil, false
}
length := obj.Get("length")
if length == nil || goja.IsUndefined(length) || goja.IsNull(length) {
return nil, false
}
n := int(length.ToInteger())
if n < 0 {
return nil, false
}
cards := make([]Card, n)
for i := 0; i < n; i++ {
entry := obj.Get(strconv.Itoa(i))
card, ok := sr.parseCard(entry)
if !ok {
return nil, false
}
cards[i] = card
}
return cards, true
}
func (sr *ScriptRuntime) parseCard(v goja.Value) (Card, bool) {
obj := v.ToObject(sr.vm)
if obj == nil {
return Card{}, false
}
sv := obj.Get("suit")
vv := obj.Get("value")
if sv == nil || vv == nil || goja.IsUndefined(sv) || goja.IsNull(sv) || goja.IsUndefined(vv) || goja.IsNull(vv) {
return Card{}, false
}
card := Card{Suit: Suit(sv.String()), Value: Value(vv.String())}
if !isValidSuit(card.Suit) || !isValidValue(card.Value) {
return Card{}, false
}
return card, true
}
func isValidSuit(s Suit) bool {
switch s {
case Hearts, Diamonds, Spades, Clubs:
return true
default:
return false
}
}
func isValidValue(v Value) bool {
switch v {
case Seven, Eight, Nine, Ten, Under, Upper, King, Ace:
return true
default:
return false
}
}