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

348
internal/api/hub.go Normal file
View file

@ -0,0 +1,348 @@
package api
import (
"crypto/rand"
"encoding/json"
"fmt"
"log"
"net/http"
"prsi/internal/engine"
"sync"
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
// Hub manages all active game rooms.
type Hub struct {
mu sync.RWMutex
games map[string]*engine.Game // gameID → game
conns map[string][]*playerConn // gameID → connected sockets
scripts map[string]string // gameID → accumulated JS rules for this room
rules map[string][]engine.ActiveRule // gameID → human-readable rule history
}
// playerConn pairs a WebSocket connection with the player ID that opened it.
type playerConn struct {
conn *websocket.Conn
playerID string
}
func NewHub() *Hub {
return &Hub{
games: make(map[string]*engine.Game),
conns: make(map[string][]*playerConn),
scripts: make(map[string]string),
rules: make(map[string][]engine.ActiveRule),
}
}
// ──────────────────────────────────────────────────────────────────────────────
// WebSocket handler
// ──────────────────────────────────────────────────────────────────────────────
type wsMessage struct {
Type string `json:"type"`
PlayerID string `json:"player_id,omitempty"`
PlayerName string `json:"player_name,omitempty"`
GameID string `json:"game_id,omitempty"`
// CardIndices supports multi-card play; CardIdx is kept for single-card backwards compat.
CardIndices []int `json:"card_indices,omitempty"`
CardIdx *int `json:"card_idx,omitempty"`
ChosenSuit engine.Suit `json:"chosen_suit,omitempty"`
Choice string `json:"choice,omitempty"`
Script string `json:"script,omitempty"`
Description string `json:"description,omitempty"`
}
func (h *Hub) HandleWS(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Println("ws upgrade:", err)
return
}
defer conn.Close()
for {
_, raw, err := conn.ReadMessage()
if err != nil {
h.removeConn(conn)
break
}
var m wsMessage
if err := json.Unmarshal(raw, &m); err != nil {
continue
}
h.handle(conn, m)
}
}
// ──────────────────────────────────────────────────────────────────────────────
// Message dispatch
// ──────────────────────────────────────────────────────────────────────────────
func (h *Hub) handle(conn *websocket.Conn, m wsMessage) {
h.mu.Lock()
defer h.mu.Unlock()
switch m.Type {
case "create":
id := generateID()
game, err := engine.NewGame(id, engine.DefaultScript)
if err != nil {
writeError(conn, err.Error())
return
}
h.games[id] = game
h.scripts[id] = engine.DefaultScript
conn.WriteJSON(map[string]string{"type": "created", "game_id": id})
case "join":
game, ok := h.games[m.GameID]
if !ok {
writeError(conn, "game not found")
return
}
if err := game.AddPlayer(m.PlayerID, m.PlayerName); err != nil {
writeError(conn, err.Error())
return
}
// Register this connection.
h.conns[m.GameID] = append(h.conns[m.GameID], &playerConn{conn: conn, playerID: m.PlayerID})
h.broadcast(m.GameID)
case "start":
game, ok := h.games[m.GameID]
if !ok {
writeError(conn, "game not found")
return
}
// If the previous game ended, recreate with accumulated script but keep players.
if game.State == engine.Ended {
players := game.Players
script := h.scripts[m.GameID]
activeRules := h.rules[m.GameID]
newGame, err := engine.NewGame(m.GameID, script)
if err != nil {
writeError(conn, "invalid rules script: "+err.Error())
return
}
newGame.ActiveRules = activeRules
h.games[m.GameID] = newGame
game = newGame
for _, p := range players {
game.AddPlayer(p.ID, p.Name) //nolint
}
}
if err := game.Start(); err != nil {
writeError(conn, err.Error())
return
}
h.broadcast(m.GameID)
case "play":
game, ok := h.games[m.GameID]
if !ok {
return
}
indices := m.CardIndices
if len(indices) == 0 && m.CardIdx != nil {
indices = []int{*m.CardIdx}
}
if err := game.PlayCards(m.PlayerID, indices, m.ChosenSuit); err != nil {
writeError(conn, err.Error())
return
}
h.broadcast(m.GameID)
case "draw":
game, ok := h.games[m.GameID]
if !ok {
return
}
if err := game.Draw(m.PlayerID); err != nil {
writeError(conn, err.Error())
return
}
h.broadcast(m.GameID)
case "choice":
game, ok := h.games[m.GameID]
if !ok {
return
}
if err := game.ApplyChoice(m.PlayerID, m.Choice); err != nil {
writeError(conn, err.Error())
return
}
h.broadcast(m.GameID)
// set_rule is sent by the winner after the LLM has produced and the client has
// confirmed a new script. The hub validates it, stores it, and broadcasts.
case "set_rule":
game, ok := h.games[m.GameID]
if !ok {
writeError(conn, "game not found")
return
}
// Only the winner may set a rule.
if game.Winner == nil || game.Winner.ID != m.PlayerID {
writeError(conn, "only the winner can propose a new rule")
return
}
// Validate the script compiles.
if _, err := engine.NewScriptRuntime(m.Script); err != nil {
writeError(conn, "invalid script: "+err.Error())
return
}
h.scripts[m.GameID] = m.Script
rule := engine.ActiveRule{
Description: m.Description,
ProposedBy: game.Winner.Name,
}
h.rules[m.GameID] = append(h.rules[m.GameID], rule)
// Broadcast so clients can display the new rule list.
h.broadcastRules(m.GameID)
}
}
// ──────────────────────────────────────────────────────────────────────────────
// HTTP handler — exposes script source for the editor panel
// ──────────────────────────────────────────────────────────────────────────────
func (h *Hub) HandleGetScript(w http.ResponseWriter, r *http.Request) {
gameID := r.URL.Query().Get("game_id")
h.mu.RLock()
script, ok := h.scripts[gameID]
h.mu.RUnlock()
if !ok {
http.Error(w, "game not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"script": script})
}
// ──────────────────────────────────────────────────────────────────────────────
// Broadcast helpers
// ──────────────────────────────────────────────────────────────────────────────
func (h *Hub) broadcast(gameID string) {
game := h.games[gameID]
conns := h.conns[gameID]
// Build per-player views so each player only sees their own hand.
for _, pc := range conns {
view := buildView(game, pc.playerID)
if err := pc.conn.WriteJSON(map[string]interface{}{
"type": "update",
"game": view,
}); err != nil {
log.Println("ws write:", err)
}
}
}
func (h *Hub) broadcastRules(gameID string) {
rules := h.rules[gameID]
script := h.scripts[gameID]
for _, pc := range h.conns[gameID] {
pc.conn.WriteJSON(map[string]interface{}{
"type": "rules_updated",
"rules": rules,
"script": script,
})
}
}
// gameView is what we send to each client — identical to Game but Hand only
// contains the *receiving* player's cards; others get a count.
type gameView struct {
ID string `json:"id"`
State engine.GameState `json:"state"`
CurrentPlayer int `json:"current_player_index"`
ActiveSuit engine.Suit `json:"active_suit"`
PenaltyCards int `json:"penalty_cards"`
SkipNext bool `json:"skip_next"`
DiscardPile []engine.Card `json:"discard_pile"`
Winner *engine.Player `json:"winner"`
PendingChoice *engine.PendingChoice `json:"pending_choice,omitempty"`
ActiveRules []engine.ActiveRule `json:"active_rules"`
Players []playerView `json:"players"`
}
type playerView struct {
ID string `json:"id"`
Name string `json:"name"`
HandCount int `json:"hand_count"`
Hand []engine.Card `json:"hand"` // non-nil only for the receiving player
}
func buildView(g *engine.Game, receiverID string) gameView {
g.Mu.RLock()
defer g.Mu.RUnlock()
players := make([]playerView, len(g.Players))
for i, p := range g.Players {
pv := playerView{
ID: p.ID,
Name: p.Name,
HandCount: len(p.Hand),
}
if p.ID == receiverID {
pv.Hand = p.Hand
}
players[i] = pv
}
// Only send the top 1 card of the discard pile to save bandwidth.
var discardTop []engine.Card
if len(g.DiscardPile) > 0 {
discardTop = g.DiscardPile[len(g.DiscardPile)-1:]
}
return gameView{
ID: g.ID,
State: g.State,
CurrentPlayer: g.CurrentPlayer,
ActiveSuit: g.ActiveSuit,
PenaltyCards: g.PenaltyCards,
SkipNext: g.SkipNext,
DiscardPile: discardTop,
Winner: g.Winner,
PendingChoice: g.PendingChoice,
ActiveRules: g.ActiveRules,
Players: players,
}
}
// ──────────────────────────────────────────────────────────────────────────────
// Utility
// ──────────────────────────────────────────────────────────────────────────────
func (h *Hub) removeConn(conn *websocket.Conn) {
h.mu.Lock()
defer h.mu.Unlock()
for gameID, list := range h.conns {
for i, pc := range list {
if pc.conn == conn {
h.conns[gameID] = append(list[:i], list[i+1:]...)
return
}
}
}
}
func writeError(conn *websocket.Conn, msg string) {
conn.WriteJSON(map[string]string{"type": "error", "message": msg})
}
func generateID() string {
b := make([]byte, 4)
rand.Read(b)
return fmt.Sprintf("%x", b)
}

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

213
internal/llm/client.go Normal file
View file

@ -0,0 +1,213 @@
// Package llm provides an OpenRouter client that rewrites the game's rules script
// in response to a natural-language rule description from the winning player.
package llm
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"regexp"
"strings"
"time"
)
// GenerateRuleRequest is the input to GenerateRule.
type GenerateRuleRequest struct {
CurrentScript string // the JS rules currently active in the room
RulePrompt string // free-text rule description from the winner
WinnerName string // for personalised flavour in the description
}
// GenerateRuleResponse is what the API endpoint returns to the browser.
// When Rejected is true, Script and Description are empty and Reason explains why.
type GenerateRuleResponse struct {
Rejected bool `json:"rejected,omitempty"` // true when the AI refuses to implement the rule
Reason string `json:"reason,omitempty"` // human-readable rejection reason
Script string `json:"script,omitempty"` // validated JS to be stored in the room
Description string `json:"description,omitempty"` // human-readable summary to display in the UI
}
const systemPrompt = `You are a rules engine for the Czech card game "Prší" (a variant of Crazy Eights).
You will be given the current JavaScript rules script and a natural-language description of a NEW rule
the winning player wants to add. Your job is to return a JSON object.
If the rule CAN be implemented, return:
{
"rejected": false,
"description": "<short (≤ 120 chars) Czech or English sentence summarising the new rule>",
"script": "<COMPLETE updated JavaScript source>"
}
If the rule CANNOT be implemented, return:
{
"rejected": true,
"reason": "<clear, friendly explanation of why, and a suggestion for what the player could try instead>"
}
The script MUST export these functions (no classes, no imports):
canPlay(cards, topCard, state) boolean
Decides whether playing the given array of cards is legal right now.
cards array of { suit, value } objects the player wants to play this turn.
IMPORTANT: engine enforces exactly ONE card, so cards.length is always 1.
topCard { suit, value } the current top of the discard pile
state object with the following fields:
activeSuit string currently required suit (may differ from topCard.suit after a wildcard)
penaltyCards number accumulated draw penalty; 0 = none active
skipNext bool true when the next player must skip unless they counter with an Ace
chosenSuit string suit the player chose when playing an Upper (wildcard)
hand Card[] the acting player's FULL hand, including cards being played
playerCount number total number of players
handCounts number[] cards held by each player (seat order)
card/topCard suit values: "hearts" | "diamonds" | "spades" | "clubs"
card/topCard value values: "7" | "8" | "9" | "10" | "under" | "upper" | "king" | "ace"
onPlayed(cards, state) {
penaltyCards?: number, new accumulated draw penalty
skipNext?: bool, new skip flag
activeSuit?: string, new active suit
instantWin?: bool, current player wins immediately (e.g. "king wins the game")
extraTurn?: bool, current player takes another turn immediately
reverseDir?: bool, set turn direction: false=clockwise (default), true=counter-clockwise
}
'state' has the same shape as above.
Only returned fields are applied; omitted fields keep their current value.
state.direction is 1 (clockwise) or -1 (counter-clockwise) useful to toggle on reverseDir.
onChoice(choice, state) same object shape as onPlayed
Called when onPlayed requests a UI choice via choicePrompt + choiceOptions.
If a script does not need choices, onChoice can just return current state unchanged.
Additional optional fields allowed in onPlayed return:
choicePrompt?: string,
choiceOptions?: string[]
When both are provided and choiceOptions has at least 1 item, frontend prompts current player and
backend calls onChoice with selected value before turn advances.
Advanced state mutation fields allowed in onPlayed/onChoice return:
playerHands?: Card[][]
discardPile?: Card[] (or playedPile as alias)
deck?: Card[]
currentPlayerIndex?: number
These fully replace corresponding engine state fields (validated by backend).
To model "discard one extra card" while keeping one-card input:
discardIndex?: number
If provided (0-based index into the current player's hand AFTER the played card was removed),
engine discards that one extra card and applies onPlayed side effects to it as well.
Use this for rules like "after King, discard one additional compatible card".
Rules about rules:
- Keep all previously implemented rules intact; only ADD the new behaviour.
- Do NOT implement multi-card moves. The engine rejects plays with cards.length != 1.
- If you need an extra discard, use discardIndex in onPlayed instead of cards.length > 1.
- For advanced custom mechanics you may mutate state directly with playerHands/discardPile/deck/currentPlayerIndex.
- You may use state.hand, state.handCounts, state.playerCount for hand-inspection rules.
- Do NOT include markdown fences, comments outside the functions, or any code other than function definitions.
- Return ONLY valid JSON no prose before or after.`
// GenerateRule calls OpenRouter to produce an updated rules script.
func GenerateRule(req GenerateRuleRequest) (GenerateRuleResponse, error) {
apiKey := os.Getenv("OPENROUTER_API_KEY")
if apiKey == "" {
return GenerateRuleResponse{}, fmt.Errorf("OPENROUTER_API_KEY environment variable is not set")
}
userMsg := fmt.Sprintf(
"Current script:\n```javascript\n%s\n```\n\nNew rule proposed by %s:\n%s",
req.CurrentScript, req.WinnerName, req.RulePrompt,
)
body, _ := json.Marshal(map[string]interface{}{
"model": modelName(),
"messages": []map[string]string{
{"role": "system", "content": systemPrompt},
{"role": "user", "content": userMsg},
},
"max_tokens": 1200,
"temperature": 0.3,
"response_format": map[string]string{"type": "json_object"},
})
httpClient := &http.Client{Timeout: 30 * time.Second}
httpReq, err := http.NewRequest("POST", "https://openrouter.ai/api/v1/chat/completions", bytes.NewReader(body))
if err != nil {
return GenerateRuleResponse{}, err
}
httpReq.Header.Set("Authorization", "Bearer "+apiKey)
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("HTTP-Referer", "https://github.com/prsi-game")
httpReq.Header.Set("X-Title", "Prší")
resp, err := httpClient.Do(httpReq)
if err != nil {
return GenerateRuleResponse{}, fmt.Errorf("OpenRouter request failed: %w", err)
}
defer resp.Body.Close()
var raw struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil {
return GenerateRuleResponse{}, fmt.Errorf("decode response: %w", err)
}
if raw.Error != nil {
return GenerateRuleResponse{}, fmt.Errorf("OpenRouter: %s", raw.Error.Message)
}
if len(raw.Choices) == 0 {
return GenerateRuleResponse{}, fmt.Errorf("empty response from OpenRouter")
}
content := raw.Choices[0].Message.Content
// Parse the JSON object the model returned.
var result GenerateRuleResponse
if err := json.Unmarshal([]byte(content), &result); err != nil {
// Try stripping accidental markdown fences.
cleaned := stripMarkdownFences(content)
if err2 := json.Unmarshal([]byte(cleaned), &result); err2 != nil {
return GenerateRuleResponse{}, fmt.Errorf("parse LLM output: %w (raw: %s)", err, content)
}
}
if result.Rejected {
if strings.TrimSpace(result.Reason) == "" {
result.Reason = "The AI was unable to implement this rule."
}
return result, nil
}
if strings.TrimSpace(result.Script) == "" {
return GenerateRuleResponse{}, fmt.Errorf("LLM returned empty script")
}
return result, nil
}
// modelName returns the OpenRouter model to use, defaultable via OPENROUTER_MODEL env.
func modelName() string {
if m := os.Getenv("OPENROUTER_MODEL"); m != "" {
return m
}
return "openai/gpt-4o-mini"
}
var reFence = regexp.MustCompile("(?s)```(?:json)?\\s*(.*?)\\s*```")
func stripMarkdownFences(s string) string {
if m := reFence.FindStringSubmatch(s); len(m) == 2 {
return m[1]
}
return s
}