459 lines
14 KiB
Go
459 lines
14 KiB
Go
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
|
||
}
|
||
}
|