This commit is contained in:
Pavel Flegr 2026-05-07 19:10:23 +02:00
commit ff5d6ab91d
7 changed files with 388 additions and 84 deletions

View file

@ -19,6 +19,7 @@ func main() {
// Expose current script for a game room (used by the script viewer panel) // Expose current script for a game room (used by the script viewer panel)
http.HandleFunc("/api/script", hub.HandleGetScript) http.HandleFunc("/api/script", hub.HandleGetScript)
http.HandleFunc("/api/lobbies", hub.HandleListLobbies)
// LLM rule-generation endpoint // LLM rule-generation endpoint
http.HandleFunc("/api/generate-rule", func(w http.ResponseWriter, r *http.Request) { http.HandleFunc("/api/generate-rule", func(w http.ResponseWriter, r *http.Request) {

View file

@ -7,6 +7,7 @@ import (
"log" "log"
"net/http" "net/http"
"prsi/internal/engine" "prsi/internal/engine"
"sort"
"sync" "sync"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
@ -49,7 +50,7 @@ type wsMessage struct {
PlayerID string `json:"player_id,omitempty"` PlayerID string `json:"player_id,omitempty"`
PlayerName string `json:"player_name,omitempty"` PlayerName string `json:"player_name,omitempty"`
GameID string `json:"game_id,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"` CardIndices []int `json:"card_indices,omitempty"`
CardIdx *int `json:"card_idx,omitempty"` CardIdx *int `json:"card_idx,omitempty"`
ChosenSuit engine.Suit `json:"chosen_suit,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}) 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 // 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 // gameView is what we send to each client — identical to Game but Hand only
// contains the *receiving* player's cards; others get a count. // contains the *receiving* player's cards; others get a count.
type gameView struct { type gameView struct {
ID string `json:"id"` ID string `json:"id"`
State engine.GameState `json:"state"` State engine.GameState `json:"state"`
CurrentPlayer int `json:"current_player_index"` CurrentPlayer int `json:"current_player_index"`
ActiveSuit engine.Suit `json:"active_suit"` TurnNumber int `json:"turn_number"`
PenaltyCards int `json:"penalty_cards"` RoundNumber int `json:"round_number"`
SkipNext bool `json:"skip_next"` LastActionType string `json:"last_action_type,omitempty"`
DiscardPile []engine.Card `json:"discard_pile"` LastActorIndex int `json:"last_actor_index,omitempty"`
Winner *engine.Player `json:"winner"` ActiveSuit engine.Suit `json:"active_suit"`
PendingChoice *engine.PendingChoice `json:"pending_choice,omitempty"` PenaltyCards int `json:"penalty_cards"`
ActiveRules []engine.ActiveRule `json:"active_rules"` SkipNext bool `json:"skip_next"`
Players []playerView `json:"players"` 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 { type playerView struct {
@ -306,17 +355,22 @@ func buildView(g *engine.Game, receiverID string) gameView {
} }
return gameView{ return gameView{
ID: g.ID, ID: g.ID,
State: g.State, State: g.State,
CurrentPlayer: g.CurrentPlayer, CurrentPlayer: g.CurrentPlayer,
ActiveSuit: g.ActiveSuit, TurnNumber: g.TurnNumber,
PenaltyCards: g.PenaltyCards, RoundNumber: g.RoundNumber,
SkipNext: g.SkipNext, LastActionType: g.LastActionType,
DiscardPile: discardTop, LastActorIndex: g.LastActorIndex,
Winner: g.Winner, ActiveSuit: g.ActiveSuit,
PendingChoice: g.PendingChoice, PenaltyCards: g.PenaltyCards,
ActiveRules: g.ActiveRules, SkipNext: g.SkipNext,
Players: players, DiscardPile: discardTop,
Winner: g.Winner,
PendingChoice: g.PendingChoice,
ChoiceQueue: g.ChoiceQueue,
ActiveRules: g.ActiveRules,
Players: players,
} }
} }

View file

@ -58,28 +58,34 @@ type ActiveRule struct {
} }
type Game struct { type Game struct {
ID string `json:"id"` ID string `json:"id"`
Players []*Player `json:"players"` Players []*Player `json:"players"`
Deck []Card `json:"-"` Deck []Card `json:"-"`
DiscardPile []Card `json:"discard_pile"` DiscardPile []Card `json:"discard_pile"`
CurrentPlayer int `json:"current_player_index"` CurrentPlayer int `json:"current_player_index"`
ActiveSuit Suit `json:"active_suit"` ActiveSuit Suit `json:"active_suit"`
PenaltyCards int `json:"penalty_cards"` PenaltyCards int `json:"penalty_cards"`
SkipNext bool `json:"skip_next"` SkipNext bool `json:"skip_next"`
Direction int `json:"direction"` // 1 = clockwise, -1 = counter-clockwise Direction int `json:"direction"` // 1 = clockwise, -1 = counter-clockwise
State GameState `json:"state"` TurnNumber int `json:"turn_number"`
Winner *Player `json:"winner"` RoundNumber int `json:"round_number"`
PendingChoice *PendingChoice `json:"pending_choice,omitempty"` LastActionType string `json:"last_action_type,omitempty"`
ActiveRules []ActiveRule `json:"active_rules"` LastActorIndex int `json:"last_actor_index,omitempty"`
Mu sync.RWMutex `json:"-"` State GameState `json:"state"`
script *ScriptRuntime 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. // PendingChoice is a UI prompt the current player must resolve before turn can continue.
type PendingChoice struct { type PendingChoice struct {
PlayerID string `json:"player_id"` PlayerID string `json:"player_id"`
Prompt string `json:"prompt"` PlayerIndex int `json:"player_index"`
Options []string `json:"options"` Prompt string `json:"prompt"`
Options []string `json:"options"`
} }
func NewGame(id, scriptSrc string) (*Game, error) { func NewGame(id, scriptSrc string) (*Game, error) {
@ -155,8 +161,13 @@ func (g *Game) Start() error {
g.PenaltyCards = 0 g.PenaltyCards = 0
g.SkipNext = false g.SkipNext = false
g.Direction = 1 g.Direction = 1
g.TurnNumber = 1
g.RoundNumber = 1
g.LastActionType = "start"
g.LastActorIndex = 0
g.Winner = nil g.Winner = nil
g.PendingChoice = nil g.PendingChoice = nil
g.ChoiceQueue = nil
for i := 0; i < 4; i++ { for i := 0; i < 4; i++ {
for _, p := range g.Players { for _, p := range g.Players {
@ -241,6 +252,7 @@ func (g *Game) PlayCards(playerID string, cardIndices []int, chosenSuit Suit) er
if p.ID != playerID { if p.ID != playerID {
return errors.New("not your turn") return errors.New("not your turn")
} }
actorIndex := g.CurrentPlayer
// Validate all indices. // Validate all indices.
seen := make(map[int]bool) 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 { if len(p.Hand) == 0 {
g.State = Ended g.State = Ended
g.Winner = p g.Winner = p
g.LastActionType = "win"
g.LastActorIndex = actorIndex
return nil return nil
} }
@ -313,15 +327,14 @@ func (g *Game) PlayCards(playerID string, cardIndices []int, chosenSuit Suit) er
// current player goes again — don't advance // current player goes again — don't advance
return nil return nil
} }
if res.ChoicePrompt != "" && len(res.ChoiceOptions) > 0 { if g.PendingChoice != nil {
g.PendingChoice = &PendingChoice{
PlayerID: p.ID,
Prompt: res.ChoicePrompt,
Options: res.ChoiceOptions,
}
return nil return nil
} }
g.LastActionType = "play"
g.LastActorIndex = actorIndex
g.nextTurn() g.nextTurn()
g.LastActionType = "draw"
g.LastActorIndex = actorIndex
return nil return nil
} }
@ -382,6 +395,7 @@ func (g *Game) ApplyChoice(playerID, choice string) error {
if g.PendingChoice.PlayerID != playerID { if g.PendingChoice.PlayerID != playerID {
return errors.New("not your choice") return errors.New("not your choice")
} }
chooserIndex := g.PendingChoice.PlayerIndex
valid := false valid := false
for _, o := range g.PendingChoice.Options { for _, o := range g.PendingChoice.Options {
@ -408,6 +422,9 @@ func (g *Game) ApplyChoice(playerID, choice string) error {
g.State = Ended g.State = Ended
g.Winner = p g.Winner = p
g.PendingChoice = nil g.PendingChoice = nil
g.ChoiceQueue = nil
g.LastActionType = "win"
g.LastActorIndex = chooserIndex
return nil return nil
} }
if res.SetDiscardIdx { if res.SetDiscardIdx {
@ -423,21 +440,16 @@ func (g *Game) ApplyChoice(playerID, choice string) error {
if err != nil { if err != nil {
log.Printf("script onPlayed error on extra discard: %v", err) log.Printf("script onPlayed error on extra discard: %v", err)
} else { } else {
g.PenaltyCards = extraRes.PenaltyCards if err := g.applyScriptStateMutation(extraRes); err != nil {
g.SkipNext = extraRes.SkipNext return err
if extraRes.ActiveSuit != "" {
g.ActiveSuit = extraRes.ActiveSuit
}
if extraRes.SetReverseDir {
g.Direction = 1
if extraRes.ReverseDir {
g.Direction = -1
}
} }
if extraRes.InstantWin { if extraRes.InstantWin {
g.State = Ended g.State = Ended
g.Winner = p g.Winner = p
g.PendingChoice = nil g.PendingChoice = nil
g.ChoiceQueue = nil
g.LastActionType = "win"
g.LastActorIndex = chooserIndex
return nil return nil
} }
if len(p.Hand) == 0 { if len(p.Hand) == 0 {
@ -446,12 +458,7 @@ func (g *Game) ApplyChoice(playerID, choice string) error {
g.PendingChoice = nil g.PendingChoice = nil
return nil return nil
} }
if extraRes.ChoicePrompt != "" && len(extraRes.ChoiceOptions) > 0 { if g.PendingChoice != nil {
g.PendingChoice = &PendingChoice{
PlayerID: p.ID,
Prompt: extraRes.ChoicePrompt,
Options: extraRes.ChoiceOptions,
}
return nil return nil
} }
if extraRes.ExtraTurn { if extraRes.ExtraTurn {
@ -464,13 +471,28 @@ func (g *Game) ApplyChoice(playerID, choice string) error {
g.State = Ended g.State = Ended
g.Winner = p g.Winner = p
g.PendingChoice = nil g.PendingChoice = nil
g.ChoiceQueue = nil
g.LastActionType = "win"
g.LastActorIndex = chooserIndex
return nil return nil
} }
g.PendingChoice = 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 return nil
} }
if res.ExtraTurn {
g.LastActionType = "choice"
g.LastActorIndex = chooserIndex
return nil
}
g.LastActionType = "choice"
g.LastActorIndex = chooserIndex
g.nextTurn() g.nextTurn()
return nil return nil
} }
@ -516,12 +538,37 @@ func (g *Game) applyScriptStateMutation(res OnPlayedResult) error {
} }
g.CurrentPlayer = res.CurrentPlayer 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 return nil
} }
func (g *Game) nextTurn() { func (g *Game) nextTurn() {
n := len(g.Players) n := len(g.Players)
prev := g.CurrentPlayer
g.CurrentPlayer = ((g.CurrentPlayer+g.Direction)%n + n) % n 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. // 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), PlayerCount: len(g.Players),
HandCounts: counts, HandCounts: counts,
Direction: g.Direction, 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
}(),
} }
} }

View file

@ -34,12 +34,17 @@ import (
// reverseDir bool — toggle turn-order direction (false = clockwise, true = counter-clockwise) // reverseDir bool — toggle turn-order direction (false = clockwise, true = counter-clockwise)
// choicePrompt string — optional UI prompt shown to current player // choicePrompt string — optional UI prompt shown to current player
// choiceOptions string[] — allowed answer options for that prompt // 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 // discardIndex number — optional index of one extra card to discard from current hand
// playerHands Card[][] — optional full replacement of all players' hands // playerHands Card[][] — optional full replacement of all players' hands
// discardPile Card[] — optional full replacement of played/discard pile // discardPile Card[] — optional full replacement of played/discard pile
// playedPile Card[] — alias for discardPile // playedPile Card[] — alias for discardPile
// deck Card[] — optional full replacement of draw deck // deck Card[] — optional full replacement of draw deck
// currentPlayerIndex number — optional current player override // currentPlayerIndex number — optional current player override
//
// state also includes richer context for advanced rules:
//
// turnNumber, roundNumber, lastActionType, lastActorIndex, pendingChoices
const DefaultScript = ` const DefaultScript = `
// ── Standard Prší rules ─────────────────────────────────────────────── // ── Standard Prší rules ───────────────────────────────────────────────
@ -96,30 +101,43 @@ type GameScriptState struct {
PlayerCount int PlayerCount int
HandCounts []int // cards held by each player, in seat order HandCounts []int // cards held by each player, in seat order
Direction int // 1 = clockwise, -1 = counter-clockwise 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. // OnPlayedResult carries every mutation onPlayed() can request.
// All fields are optional; the engine only applies what the script explicitly returns. // All fields are optional; the engine only applies what the script explicitly returns.
type OnPlayedResult struct { type OnPlayedResult struct {
PenaltyCards int PenaltyCards int
SkipNext bool SkipNext bool
ActiveSuit Suit ActiveSuit Suit
InstantWin bool // current player wins immediately InstantWin bool // current player wins immediately
ExtraTurn bool // current player takes another turn ExtraTurn bool // current player takes another turn
SetReverseDir bool // whether to apply the ReverseDir value SetReverseDir bool // whether to apply the ReverseDir value
ReverseDir bool // new direction: false=CW, true=CCW (only used when SetReverseDir=true) ReverseDir bool // new direction: false=CW, true=CCW (only used when SetReverseDir=true)
ChoicePrompt string ChoicePrompt string
ChoiceOptions []string ChoiceOptions []string
SetDiscardIdx bool SetDiscardIdx bool
DiscardIdx int DiscardIdx int
SetPlayerHands bool SetPlayerHands bool
PlayerHands [][]Card PlayerHands [][]Card
SetDiscardPile bool SetDiscardPile bool
DiscardPile []Card DiscardPile []Card
SetDeck bool SetDeck bool
Deck []Card Deck []Card
SetCurrentPlayer bool SetCurrentPlayer bool
CurrentPlayer int CurrentPlayer int
SetChoiceTargetPlayer bool
ChoiceTargetPlayer int
} }
// ScriptRuntime holds a compiled goja VM. NOT goroutine-safe. // 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 { for i, n := range s.HandCounts {
counts[i] = n 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{}{ return sr.vm.ToValue(map[string]interface{}{
"activeSuit": string(s.ActiveSuit), "activeSuit": string(s.ActiveSuit),
"penaltyCards": s.PenaltyCards, "penaltyCards": s.PenaltyCards,
@ -234,6 +264,11 @@ func (sr *ScriptRuntime) stateVal(s GameScriptState) goja.Value {
"playerCount": s.PlayerCount, "playerCount": s.PlayerCount,
"handCounts": counts, "handCounts": counts,
"direction": s.Direction, "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.CurrentPlayer = int(cp.ToInteger())
out.SetCurrentPlayer = true out.SetCurrentPlayer = true
} }
if tp := objGet(sr.vm, obj, "choiceTargetPlayerIndex"); tp != nil {
out.ChoiceTargetPlayer = int(tp.ToInteger())
out.SetChoiceTargetPlayer = true
}
return out return out
} }

View file

@ -64,6 +64,11 @@ The script MUST export these functions (no classes, no imports):
currentPlayerIndex number acting player seat index currentPlayerIndex number acting player seat index
playerCount number total number of players playerCount number total number of players
handCounts number[] cards held by each player (seat order) 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 suit values: "hearts" | "diamonds" | "spades" | "clubs"
card/topCard value values: "7" | "8" | "9" | "10" | "under" | "upper" | "king" | "ace" 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: Additional optional fields allowed in onPlayed return:
choicePrompt?: string, choicePrompt?: string,
choiceOptions?: string[] choiceOptions?: string[]
choiceTargetPlayerIndex?: number
When both are provided and choiceOptions has at least 1 item, frontend prompts current player and 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: Advanced state mutation fields allowed in onPlayed/onChoice return:
playerHands?: Card[][] playerHands?: Card[][]

View file

@ -21,6 +21,7 @@ func main() {
// Expose current script for a game room (used by the script viewer panel) // Expose current script for a game room (used by the script viewer panel)
http.HandleFunc("/api/script", hub.HandleGetScript) http.HandleFunc("/api/script", hub.HandleGetScript)
http.HandleFunc("/api/lobbies", hub.HandleListLobbies)
// LLM rule-generation endpoint // LLM rule-generation endpoint
http.HandleFunc("/api/generate-rule", func(w http.ResponseWriter, r *http.Request) { http.HandleFunc("/api/generate-rule", func(w http.ResponseWriter, r *http.Request) {

View file

@ -136,6 +136,73 @@
background: rgba(255,255,255,.15); 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 TABLE LAYOUT
══════════════════════════════ */ ══════════════════════════════ */
@ -957,6 +1024,14 @@
<div class="invite-id" id="invite-id-text" onclick="copyCode()" title="Klikni pro zkopírování"></div> <div class="invite-id" id="invite-id-text" onclick="copyCode()" title="Klikni pro zkopírování"></div>
<div class="invite-hint">Klikni na kód pro zkopírování · Čekáme na další hráče…</div> <div class="invite-hint">Klikni na kód pro zkopírování · Čekáme na další hráče…</div>
</div> </div>
<div class="lobby-list-wrap">
<div class="lobby-list-head">
<div class="lobby-list-title">Veřejné lobby</div>
<button class="btn-refresh-lobbies" onclick="loadLobbies()">Obnovit</button>
</div>
<div id="lobby-list"></div>
</div>
</div> </div>
</div> </div>
@ -1128,6 +1203,7 @@ let restartFallbackTimer = null;
let joinPending = false; let joinPending = false;
let tableVisible = false; let tableVisible = false;
let currentScriptOpen = false; let currentScriptOpen = false;
let lobbyPollTimer = null;
const SUIT_ICONS = { hearts:'♥', diamonds:'♦', spades:'♠', clubs:'♣' }; const SUIT_ICONS = { hearts:'♥', diamonds:'♦', spades:'♠', clubs:'♣' };
const SUIT_NAMES = { hearts:'Srdce', diamonds:'Kára', spades:'Piky', clubs:'Kříže' }; const SUIT_NAMES = { hearts:'Srdce', diamonds:'Kára', spades:'Piky', clubs:'Kříže' };
@ -1206,12 +1282,21 @@ function joinExisting() {
sendJoin(); sendJoin();
} }
function joinLobby(id) {
myName = document.getElementById('player-name').value.trim() || 'Hráč';
gameId = String(id || '').toLowerCase();
if (!gameId) return;
document.getElementById('game-id-input').value = gameId.toUpperCase();
sendJoin();
}
function sendJoin() { function sendJoin() {
joinPending = true; joinPending = true;
ws.send(JSON.stringify({ type:'join', player_id:myId, player_name:myName, game_id:gameId })); ws.send(JSON.stringify({ type:'join', player_id:myId, player_name:myName, game_id:gameId }));
} }
function switchToTable() { function switchToTable() {
stopLobbyPolling();
document.getElementById('lobby').style.display = 'none'; document.getElementById('lobby').style.display = 'none';
document.getElementById('table').style.display = 'flex'; document.getElementById('table').style.display = 'flex';
document.getElementById('topbar-id').textContent = gameId.toUpperCase(); document.getElementById('topbar-id').textContent = gameId.toUpperCase();
@ -1222,6 +1307,50 @@ function copyCode() {
navigator.clipboard.writeText(gameId.toUpperCase()).then(() => toast('Kód zkopírován: ' + gameId.toUpperCase())); navigator.clipboard.writeText(gameId.toUpperCase()).then(() => toast('Kód zkopírován: ' + gameId.toUpperCase()));
} }
async function loadLobbies() {
const list = document.getElementById('lobby-list');
if (!list) return;
try {
const resp = await fetch('/api/lobbies');
if (!resp.ok) throw new Error(resp.statusText);
const data = await resp.json();
const lobbies = data.lobbies || [];
if (lobbies.length === 0) {
list.innerHTML = `<div class="lobby-empty">Zatím žádné čekající hry</div>`;
return;
}
list.innerHTML = lobbies.map((l) => {
const players = (l.players || []).map(escHtml).join(', ');
const rules = l.has_rules ? ' · vlastní pravidla' : '';
const safeID = escHtml(String(l.game_id || '').toUpperCase());
const onclickID = String(l.game_id || '').replace(/'/g, "\\'");
return `
<div class="lobby-item">
<div>
<div class="lobby-id">${safeID}</div>
<div class="lobby-meta">${l.player_count || 0}/4 hráči${rules}${players ? ` · ${players}` : ''}</div>
</div>
<button class="btn-join-lobby" onclick="joinLobby('${onclickID}')">Připojit</button>
</div>
`;
}).join('');
} catch (err) {
list.innerHTML = `<div class="lobby-empty">Nelze načíst lobby (${escHtml(err.message)})</div>`;
}
}
function startLobbyPolling() {
stopLobbyPolling();
loadLobbies();
lobbyPollTimer = setInterval(loadLobbies, 5000);
}
function stopLobbyPolling() {
if (!lobbyPollTimer) return;
clearInterval(lobbyPollTimer);
lobbyPollTimer = null;
}
// ── Game actions ─────────────────────────────────────────────────────── // ── Game actions ───────────────────────────────────────────────────────
function startGame() { function startGame() {
ws.send(JSON.stringify({ type:'start', game_id:gameId })); ws.send(JSON.stringify({ type:'start', game_id:gameId }));
@ -1836,7 +1965,11 @@ function highlight(code) {
function checkUrlJoin() { function checkUrlJoin() {
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
const code = params.get('game'); const code = params.get('game');
if (code) document.getElementById('game-id-input').value = code.toLowerCase(); if (code) {
document.getElementById('game-id-input').value = code.toLowerCase();
} else {
startLobbyPolling();
}
} }
connect(); connect();