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 // choiceTargetPlayerIndex number — optional target player for the choice 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 // // state also includes richer context for advanced rules: // // turnNumber, roundNumber, lastActionType, lastActorIndex, pendingChoices 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) PlayerHands [][]Card CurrentPlayerIndex int PlayerCount int HandCounts []int // cards held by each player, in seat order Direction int // 1 = clockwise, -1 = counter-clockwise TurnNumber int RoundNumber int LastActionType string LastActorIndex int PendingChoices []ScriptPendingChoice } type ScriptPendingChoice struct { TargetPlayerIndex int Prompt string Options []string } // 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 SetChoiceTargetPlayer bool ChoiceTargetPlayer 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)} } playerHands := make([]interface{}, len(s.PlayerHands)) for i, h := range s.PlayerHands { cards := make([]interface{}, len(h)) for j, c := range h { cards[j] = map[string]string{"suit": string(c.Suit), "value": string(c.Value)} } playerHands[i] = cards } counts := make([]interface{}, len(s.HandCounts)) for i, n := range s.HandCounts { counts[i] = n } pending := make([]interface{}, len(s.PendingChoices)) for i, ch := range s.PendingChoices { opts := make([]interface{}, len(ch.Options)) for j, o := range ch.Options { opts[j] = o } pending[i] = map[string]interface{}{ "targetPlayerIndex": ch.TargetPlayerIndex, "prompt": ch.Prompt, "options": opts, } } return sr.vm.ToValue(map[string]interface{}{ "activeSuit": string(s.ActiveSuit), "penaltyCards": s.PenaltyCards, "skipNext": s.SkipNext, "chosenSuit": string(s.ChosenSuit), "hand": hand, "playerHands": playerHands, "currentPlayerIndex": s.CurrentPlayerIndex, "playerCount": s.PlayerCount, "handCounts": counts, "direction": s.Direction, "turnNumber": s.TurnNumber, "roundNumber": s.RoundNumber, "lastActionType": s.LastActionType, "lastActorIndex": s.LastActorIndex, "pendingChoices": pending, }) } // ── 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 } if tp := objGet(sr.vm, obj, "choiceTargetPlayerIndex"); tp != nil { out.ChoiceTargetPlayer = int(tp.ToInteger()) out.SetChoiceTargetPlayer = 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 } }