bla
This commit is contained in:
parent
0736d22ebe
commit
b3f02ea15e
10 changed files with 3567 additions and 0 deletions
63
cmd/server/main.go
Normal file
63
cmd/server/main.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
13
go.mod
Normal file
13
go.mod
Normal file
|
|
@ -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
|
||||||
|
)
|
||||||
12
go.sum
Normal file
12
go.sum
Normal file
|
|
@ -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=
|
||||||
348
internal/api/hub.go
Normal file
348
internal/api/hub.go
Normal 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
548
internal/engine/game.go
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
273
internal/engine/game_test.go
Normal file
273
internal/engine/game_test.go
Normal 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
408
internal/engine/script.go
Normal 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
213
internal/llm/client.go
Normal 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
|
||||||
|
}
|
||||||
BIN
prsi-server
Executable file
BIN
prsi-server
Executable file
Binary file not shown.
1689
web/static/index.html
Normal file
1689
web/static/index.html
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue