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

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
}
}