diff --git a/cmd/server/main.go b/cmd/server/main.go index 81255e7..69d48de 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -19,6 +19,7 @@ func main() { // Expose current script for a game room (used by the script viewer panel) http.HandleFunc("/api/script", hub.HandleGetScript) + http.HandleFunc("/api/lobbies", hub.HandleListLobbies) // LLM rule-generation endpoint http.HandleFunc("/api/generate-rule", func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/api/hub.go b/internal/api/hub.go index 838254b..d920b0f 100644 --- a/internal/api/hub.go +++ b/internal/api/hub.go @@ -7,6 +7,7 @@ import ( "log" "net/http" "prsi/internal/engine" + "sort" "sync" "github.com/gorilla/websocket" @@ -49,7 +50,7 @@ type wsMessage struct { 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 carries selected card index; CardIdx is kept for backwards compat. CardIndices []int `json:"card_indices,omitempty"` CardIdx *int `json:"card_idx,omitempty"` ChosenSuit engine.Suit `json:"chosen_suit,omitempty"` @@ -227,6 +228,49 @@ func (h *Hub) HandleGetScript(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]string{"script": script}) } +type lobbyInfo struct { + GameID string `json:"game_id"` + PlayerCount int `json:"player_count"` + Players []string `json:"players"` + State engine.GameState `json:"state"` + HasRules bool `json:"has_rules"` +} + +// HandleListLobbies returns waiting rooms that can be joined. +func (h *Hub) HandleListLobbies(w http.ResponseWriter, r *http.Request) { + h.mu.RLock() + defer h.mu.RUnlock() + + rooms := make([]lobbyInfo, 0, len(h.games)) + for id, g := range h.games { + g.Mu.RLock() + if g.State == engine.Waiting && len(g.Players) < 4 { + names := make([]string, 0, len(g.Players)) + for _, p := range g.Players { + names = append(names, p.Name) + } + rooms = append(rooms, lobbyInfo{ + GameID: id, + PlayerCount: len(g.Players), + Players: names, + State: g.State, + HasRules: len(g.ActiveRules) > 0, + }) + } + g.Mu.RUnlock() + } + + sort.Slice(rooms, func(i, j int) bool { + if rooms[i].PlayerCount != rooms[j].PlayerCount { + return rooms[i].PlayerCount > rooms[j].PlayerCount + } + return rooms[i].GameID < rooms[j].GameID + }) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"lobbies": rooms}) +} + // ────────────────────────────────────────────────────────────────────────────── // Broadcast helpers // ────────────────────────────────────────────────────────────────────────────── @@ -262,17 +306,22 @@ func (h *Hub) broadcastRules(gameID string) { // 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"` + ID string `json:"id"` + State engine.GameState `json:"state"` + CurrentPlayer int `json:"current_player_index"` + TurnNumber int `json:"turn_number"` + RoundNumber int `json:"round_number"` + LastActionType string `json:"last_action_type,omitempty"` + LastActorIndex int `json:"last_actor_index,omitempty"` + 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"` + ChoiceQueue []engine.PendingChoice `json:"choice_queue,omitempty"` + ActiveRules []engine.ActiveRule `json:"active_rules"` + Players []playerView `json:"players"` } type playerView struct { @@ -306,17 +355,22 @@ func buildView(g *engine.Game, receiverID string) gameView { } 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, + ID: g.ID, + State: g.State, + CurrentPlayer: g.CurrentPlayer, + TurnNumber: g.TurnNumber, + RoundNumber: g.RoundNumber, + LastActionType: g.LastActionType, + LastActorIndex: g.LastActorIndex, + ActiveSuit: g.ActiveSuit, + PenaltyCards: g.PenaltyCards, + SkipNext: g.SkipNext, + DiscardPile: discardTop, + Winner: g.Winner, + PendingChoice: g.PendingChoice, + ChoiceQueue: g.ChoiceQueue, + ActiveRules: g.ActiveRules, + Players: players, } } diff --git a/internal/engine/game.go b/internal/engine/game.go index 5a37507..886aaa2 100644 --- a/internal/engine/game.go +++ b/internal/engine/game.go @@ -58,28 +58,34 @@ type ActiveRule struct { } 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 + 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 + TurnNumber int `json:"turn_number"` + RoundNumber int `json:"round_number"` + LastActionType string `json:"last_action_type,omitempty"` + LastActorIndex int `json:"last_actor_index,omitempty"` + State GameState `json:"state"` + Winner *Player `json:"winner"` + PendingChoice *PendingChoice `json:"pending_choice,omitempty"` + ChoiceQueue []PendingChoice `json:"choice_queue,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"` + PlayerID string `json:"player_id"` + PlayerIndex int `json:"player_index"` + Prompt string `json:"prompt"` + Options []string `json:"options"` } func NewGame(id, scriptSrc string) (*Game, error) { @@ -155,8 +161,13 @@ func (g *Game) Start() error { g.PenaltyCards = 0 g.SkipNext = false g.Direction = 1 + g.TurnNumber = 1 + g.RoundNumber = 1 + g.LastActionType = "start" + g.LastActorIndex = 0 g.Winner = nil g.PendingChoice = nil + g.ChoiceQueue = nil for i := 0; i < 4; i++ { for _, p := range g.Players { @@ -241,6 +252,7 @@ func (g *Game) PlayCards(playerID string, cardIndices []int, chosenSuit Suit) er if p.ID != playerID { return errors.New("not your turn") } + actorIndex := g.CurrentPlayer // Validate all indices. seen := make(map[int]bool) @@ -306,6 +318,8 @@ func (g *Game) PlayCards(playerID string, cardIndices []int, chosenSuit Suit) er if len(p.Hand) == 0 { g.State = Ended g.Winner = p + g.LastActionType = "win" + g.LastActorIndex = actorIndex return nil } @@ -313,15 +327,14 @@ func (g *Game) PlayCards(playerID string, cardIndices []int, chosenSuit Suit) er // 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, - } + if g.PendingChoice != nil { return nil } + g.LastActionType = "play" + g.LastActorIndex = actorIndex g.nextTurn() + g.LastActionType = "draw" + g.LastActorIndex = actorIndex return nil } @@ -382,6 +395,7 @@ func (g *Game) ApplyChoice(playerID, choice string) error { if g.PendingChoice.PlayerID != playerID { return errors.New("not your choice") } + chooserIndex := g.PendingChoice.PlayerIndex valid := false for _, o := range g.PendingChoice.Options { @@ -408,6 +422,9 @@ func (g *Game) ApplyChoice(playerID, choice string) error { g.State = Ended g.Winner = p g.PendingChoice = nil + g.ChoiceQueue = nil + g.LastActionType = "win" + g.LastActorIndex = chooserIndex return nil } if res.SetDiscardIdx { @@ -423,21 +440,16 @@ func (g *Game) ApplyChoice(playerID, choice string) error { 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 err := g.applyScriptStateMutation(extraRes); err != nil { + return err } if extraRes.InstantWin { g.State = Ended g.Winner = p g.PendingChoice = nil + g.ChoiceQueue = nil + g.LastActionType = "win" + g.LastActorIndex = chooserIndex return nil } if len(p.Hand) == 0 { @@ -446,12 +458,7 @@ func (g *Game) ApplyChoice(playerID, choice string) error { g.PendingChoice = nil return nil } - if extraRes.ChoicePrompt != "" && len(extraRes.ChoiceOptions) > 0 { - g.PendingChoice = &PendingChoice{ - PlayerID: p.ID, - Prompt: extraRes.ChoicePrompt, - Options: extraRes.ChoiceOptions, - } + if g.PendingChoice != nil { return nil } if extraRes.ExtraTurn { @@ -464,13 +471,28 @@ func (g *Game) ApplyChoice(playerID, choice string) error { g.State = Ended g.Winner = p g.PendingChoice = nil + g.ChoiceQueue = nil + g.LastActionType = "win" + g.LastActorIndex = chooserIndex return nil } g.PendingChoice = nil - if res.ExtraTurn { + if len(g.ChoiceQueue) > 0 { + next := g.ChoiceQueue[0] + g.ChoiceQueue = g.ChoiceQueue[1:] + g.PendingChoice = &next + g.LastActionType = "choice_pending" + g.LastActorIndex = chooserIndex return nil } + if res.ExtraTurn { + g.LastActionType = "choice" + g.LastActorIndex = chooserIndex + return nil + } + g.LastActionType = "choice" + g.LastActorIndex = chooserIndex g.nextTurn() return nil } @@ -516,12 +538,37 @@ func (g *Game) applyScriptStateMutation(res OnPlayedResult) error { } g.CurrentPlayer = res.CurrentPlayer } + if res.ChoicePrompt != "" && len(res.ChoiceOptions) > 0 { + target := g.CurrentPlayer + if res.SetChoiceTargetPlayer { + if res.ChoiceTargetPlayer < 0 || res.ChoiceTargetPlayer >= len(g.Players) { + return errors.New("invalid choiceTargetPlayerIndex") + } + target = res.ChoiceTargetPlayer + } + ch := PendingChoice{ + PlayerID: g.Players[target].ID, + PlayerIndex: target, + Prompt: res.ChoicePrompt, + Options: append([]string(nil), res.ChoiceOptions...), + } + if g.PendingChoice == nil { + g.PendingChoice = &ch + } else { + g.ChoiceQueue = append(g.ChoiceQueue, ch) + } + } return nil } func (g *Game) nextTurn() { n := len(g.Players) + prev := g.CurrentPlayer g.CurrentPlayer = ((g.CurrentPlayer+g.Direction)%n + n) % n + g.TurnNumber++ + if g.CurrentPlayer == 0 && prev != 0 { + g.RoundNumber++ + } } // buildState constructs the GameScriptState for the current moment. @@ -550,5 +597,27 @@ func (g *Game) buildState(p *Player, chosenSuit Suit) GameScriptState { PlayerCount: len(g.Players), HandCounts: counts, Direction: g.Direction, + TurnNumber: g.TurnNumber, + RoundNumber: g.RoundNumber, + LastActionType: g.LastActionType, + LastActorIndex: g.LastActorIndex, + PendingChoices: func() []ScriptPendingChoice { + out := make([]ScriptPendingChoice, 0, 1+len(g.ChoiceQueue)) + if g.PendingChoice != nil { + out = append(out, ScriptPendingChoice{ + TargetPlayerIndex: g.PendingChoice.PlayerIndex, + Prompt: g.PendingChoice.Prompt, + Options: append([]string(nil), g.PendingChoice.Options...), + }) + } + for _, q := range g.ChoiceQueue { + out = append(out, ScriptPendingChoice{ + TargetPlayerIndex: q.PlayerIndex, + Prompt: q.Prompt, + Options: append([]string(nil), q.Options...), + }) + } + return out + }(), } } diff --git a/internal/engine/script.go b/internal/engine/script.go index 428c416..1ad8651 100644 --- a/internal/engine/script.go +++ b/internal/engine/script.go @@ -34,12 +34,17 @@ import ( // reverseDir bool — toggle turn-order direction (false = clockwise, true = counter-clockwise) // choicePrompt string — optional UI prompt shown to current player // choiceOptions string[] — allowed answer options for that prompt +// choiceTargetPlayerIndex number — optional target player for the choice prompt // discardIndex number — optional index of one extra card to discard from current hand // playerHands Card[][] — optional full replacement of all players' hands // discardPile Card[] — optional full replacement of played/discard pile // playedPile Card[] — alias for discardPile // deck Card[] — optional full replacement of draw deck // currentPlayerIndex number — optional current player override +// +// state also includes richer context for advanced rules: +// +// turnNumber, roundNumber, lastActionType, lastActorIndex, pendingChoices const DefaultScript = ` // ── Standard Prší rules ─────────────────────────────────────────────── @@ -96,30 +101,43 @@ type GameScriptState struct { PlayerCount int HandCounts []int // cards held by each player, in seat order Direction int // 1 = clockwise, -1 = counter-clockwise + TurnNumber int + RoundNumber int + LastActionType string + LastActorIndex int + PendingChoices []ScriptPendingChoice +} + +type ScriptPendingChoice struct { + TargetPlayerIndex int + Prompt string + Options []string } // OnPlayedResult carries every mutation onPlayed() can request. // All fields are optional; the engine only applies what the script explicitly returns. type OnPlayedResult struct { - PenaltyCards int - SkipNext bool - ActiveSuit Suit - InstantWin bool // current player wins immediately - ExtraTurn bool // current player takes another turn - SetReverseDir bool // whether to apply the ReverseDir value - ReverseDir bool // new direction: false=CW, true=CCW (only used when SetReverseDir=true) - ChoicePrompt string - ChoiceOptions []string - SetDiscardIdx bool - DiscardIdx int - SetPlayerHands bool - PlayerHands [][]Card - SetDiscardPile bool - DiscardPile []Card - SetDeck bool - Deck []Card - SetCurrentPlayer bool - CurrentPlayer int + PenaltyCards int + SkipNext bool + ActiveSuit Suit + InstantWin bool // current player wins immediately + ExtraTurn bool // current player takes another turn + SetReverseDir bool // whether to apply the ReverseDir value + ReverseDir bool // new direction: false=CW, true=CCW (only used when SetReverseDir=true) + ChoicePrompt string + ChoiceOptions []string + SetDiscardIdx bool + DiscardIdx int + SetPlayerHands bool + PlayerHands [][]Card + SetDiscardPile bool + DiscardPile []Card + SetDeck bool + Deck []Card + SetCurrentPlayer bool + CurrentPlayer int + SetChoiceTargetPlayer bool + ChoiceTargetPlayer int } // ScriptRuntime holds a compiled goja VM. NOT goroutine-safe. @@ -223,6 +241,18 @@ func (sr *ScriptRuntime) stateVal(s GameScriptState) goja.Value { for i, n := range s.HandCounts { counts[i] = n } + pending := make([]interface{}, len(s.PendingChoices)) + for i, ch := range s.PendingChoices { + opts := make([]interface{}, len(ch.Options)) + for j, o := range ch.Options { + opts[j] = o + } + pending[i] = map[string]interface{}{ + "targetPlayerIndex": ch.TargetPlayerIndex, + "prompt": ch.Prompt, + "options": opts, + } + } return sr.vm.ToValue(map[string]interface{}{ "activeSuit": string(s.ActiveSuit), "penaltyCards": s.PenaltyCards, @@ -234,6 +264,11 @@ func (sr *ScriptRuntime) stateVal(s GameScriptState) goja.Value { "playerCount": s.PlayerCount, "handCounts": counts, "direction": s.Direction, + "turnNumber": s.TurnNumber, + "roundNumber": s.RoundNumber, + "lastActionType": s.LastActionType, + "lastActorIndex": s.LastActorIndex, + "pendingChoices": pending, }) } @@ -319,6 +354,10 @@ func (sr *ScriptRuntime) parseResult(v goja.Value, defaults OnPlayedResult) OnPl out.CurrentPlayer = int(cp.ToInteger()) out.SetCurrentPlayer = true } + if tp := objGet(sr.vm, obj, "choiceTargetPlayerIndex"); tp != nil { + out.ChoiceTargetPlayer = int(tp.ToInteger()) + out.SetChoiceTargetPlayer = true + } return out } diff --git a/internal/llm/client.go b/internal/llm/client.go index bbdf35c..500a91e 100644 --- a/internal/llm/client.go +++ b/internal/llm/client.go @@ -64,6 +64,11 @@ The script MUST export these functions (no classes, no imports): currentPlayerIndex number — acting player seat index playerCount number — total number of players handCounts number[] — cards held by each player (seat order) + turnNumber number — increments each time turn advances + roundNumber number — increments when turn wraps to seat 0 + lastActionType string — start|play|draw|choice|choice_pending|win + lastActorIndex number — seat index of actor in last action + pendingChoices object[] — pending choice queue (each has targetPlayerIndex,prompt,options) card/topCard suit values: "hearts" | "diamonds" | "spades" | "clubs" card/topCard value values: "7" | "8" | "9" | "10" | "under" | "upper" | "king" | "ace" @@ -87,8 +92,10 @@ The script MUST export these functions (no classes, no imports): Additional optional fields allowed in onPlayed return: choicePrompt?: string, choiceOptions?: string[] + choiceTargetPlayerIndex?: number 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. + backend calls onChoice with selected value before turn advances. If a choice is already pending, + this new one is queued (chained choices). Advanced state mutation fields allowed in onPlayed/onChoice return: playerHands?: Card[][] diff --git a/main.go b/main.go index 4164f51..5f0d95e 100644 --- a/main.go +++ b/main.go @@ -21,6 +21,7 @@ func main() { // Expose current script for a game room (used by the script viewer panel) http.HandleFunc("/api/script", hub.HandleGetScript) + http.HandleFunc("/api/lobbies", hub.HandleListLobbies) // LLM rule-generation endpoint http.HandleFunc("/api/generate-rule", func(w http.ResponseWriter, r *http.Request) { diff --git a/web/static/index.html b/web/static/index.html index ac9ec93..0072ea2 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -136,6 +136,73 @@ background: rgba(255,255,255,.15); } + .lobby-list-wrap { + margin-top: 16px; + text-align: left; + } + .lobby-list-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; + } + .lobby-list-title { + font-size: .72rem; + letter-spacing: 1px; + text-transform: uppercase; + color: rgba(255,255,255,.55); + } + .btn-refresh-lobbies { + background: rgba(255,255,255,.1); + border: 1px solid rgba(255,255,255,.18); + color: rgba(255,255,255,.85); + border-radius: 8px; + padding: 5px 9px; + font-size: .72rem; + cursor: pointer; + } + .btn-refresh-lobbies:hover { background: rgba(255,255,255,.18); } + #lobby-list { + display: flex; + flex-direction: column; + gap: 8px; + max-height: 210px; + overflow-y: auto; + padding-right: 2px; + } + .lobby-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + background: rgba(255,255,255,.06); + border: 1px solid rgba(255,255,255,.12); + border-radius: 10px; + padding: 10px 12px; + } + .lobby-id { font-family: ui-monospace, Menlo, Consolas, monospace; color: var(--gold); font-size: .86rem; } + .lobby-meta { font-size: .72rem; color: rgba(255,255,255,.6); margin-top: 2px; } + .btn-join-lobby { + background: var(--gold); + color: #1a1a1a; + border: none; + border-radius: 8px; + padding: 7px 10px; + font-size: .78rem; + font-weight: 700; + cursor: pointer; + } + .btn-join-lobby:hover { opacity: .9; } + .lobby-empty { + font-size: .78rem; + color: rgba(255,255,255,.55); + background: rgba(255,255,255,.04); + border: 1px dashed rgba(255,255,255,.15); + border-radius: 10px; + padding: 12px; + text-align: center; + } + /* ══════════════════════════════ TABLE LAYOUT ══════════════════════════════ */ @@ -957,6 +1024,14 @@