diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..81255e7 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,63 @@ +package main + +import ( + "encoding/json" + "log" + "net/http" + "prsi/internal/api" + "prsi/internal/llm" +) + +func main() { + hub := api.NewHub() + + // Static files + http.Handle("/", http.FileServer(http.Dir("./web/static"))) + + // WebSocket + http.HandleFunc("/ws", hub.HandleWS) + + // Expose current script for a game room (used by the script viewer panel) + http.HandleFunc("/api/script", hub.HandleGetScript) + + // LLM rule-generation endpoint + http.HandleFunc("/api/generate-rule", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + CurrentScript string `json:"current_script"` + RulePrompt string `json:"rule_prompt"` + WinnerName string `json:"winner_name"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + if req.RulePrompt == "" { + http.Error(w, "rule_prompt is required", http.StatusBadRequest) + return + } + + result, err := llm.GenerateRule(llm.GenerateRuleRequest{ + CurrentScript: req.CurrentScript, + RulePrompt: req.RulePrompt, + WinnerName: req.WinnerName, + }) + if err != nil { + log.Println("generate-rule error:", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(result) + }) + + log.Println("Server starting on :8080") + if err := http.ListenAndServe(":8080", nil); err != nil { + log.Fatal("ListenAndServe:", err) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6eb1e00 --- /dev/null +++ b/go.mod @@ -0,0 +1,13 @@ +module prsi + +go 1.25.6 + +require github.com/gorilla/websocket v1.5.3 + +require ( + github.com/dlclark/regexp2 v1.11.4 // indirect + github.com/dop251/goja v0.0.0-20260311135729-065cd970411c // indirect + github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect + github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect + golang.org/x/text v0.3.8 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ac164f9 --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= +github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/dop251/goja v0.0.0-20260311135729-065cd970411c h1:OcLmPfx1T1RmZVHHFwWMPaZDdRf0DBMZOFMVWJa7Pdk= +github.com/dop251/goja v0.0.0-20260311135729-065cd970411c/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4= +github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU= +github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= +github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U= +github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= diff --git a/internal/api/hub.go b/internal/api/hub.go new file mode 100644 index 0000000..838254b --- /dev/null +++ b/internal/api/hub.go @@ -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) +} diff --git a/internal/engine/game.go b/internal/engine/game.go new file mode 100644 index 0000000..9015470 --- /dev/null +++ b/internal/engine/game.go @@ -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, + } +} diff --git a/internal/engine/game_test.go b/internal/engine/game_test.go new file mode 100644 index 0000000..1d57cf1 --- /dev/null +++ b/internal/engine/game_test.go @@ -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) + } +} diff --git a/internal/engine/script.go b/internal/engine/script.go new file mode 100644 index 0000000..d1ebac4 --- /dev/null +++ b/internal/engine/script.go @@ -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 + } +} diff --git a/internal/llm/client.go b/internal/llm/client.go new file mode 100644 index 0000000..c149e15 --- /dev/null +++ b/internal/llm/client.go @@ -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": "", + "script": "" + } + +If the rule CANNOT be implemented, return: + { + "rejected": true, + "reason": "" + } + +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 +} diff --git a/prsi-server b/prsi-server new file mode 100755 index 0000000..6ace9b7 Binary files /dev/null and b/prsi-server differ diff --git a/web/static/index.html b/web/static/index.html new file mode 100644 index 0000000..e28d5f4 --- /dev/null +++ b/web/static/index.html @@ -0,0 +1,1689 @@ + + + + + + Prší + + + + + +
+
+
PRŠÍ
+
Česká karetní hra
+ +
+ + +
+ +
nebo
+ +
+ + +
+ +
+
Kód tvé hry — sdílej ho přátelům
+
+
Klikni na kód pro zkopírování · Čekáme na další hráče…
+
+
+
+ + +
+ + +
+
Hra:
+
Čekání…
+
+
+ + +
+ + +
+ + +
+
Balíček
+
+
+
+
+
Líznout / Stát
+
+
+ + +
+
+
Aktivní barva
+
+ + +
+
Hromádka
+
+?
+
+
+ +
+ + +
+
Tvoje karty
+
+
+
+ + +
+ +
+ +
+ + +
+
+

Vyber barvu

+
+ + + + +
+
+
+ + +
+
+
🏆
+
+
Hra skončila
+ + +
+ + + + + +
+ + +
+
+
+ + +
+ Vybrané karty: 0 + + +
+ + +
+ + + + +
+ + + +