prsi/internal/engine/script.go
Pavel Flegr 7b54ed8e53 a
2026-05-07 19:46:01 +02:00

472 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
DiscardPile []Card
Deck []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
}
discardPile := make([]interface{}, len(s.DiscardPile))
for i, c := range s.DiscardPile {
discardPile[i] = map[string]string{"suit": string(c.Suit), "value": string(c.Value)}
}
deck := make([]interface{}, len(s.Deck))
for i, c := range s.Deck {
deck[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
}
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,
"discardPile": discardPile,
"deck": deck,
"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
}
}