asd
This commit is contained in:
parent
ef045f9353
commit
ff5d6ab91d
7 changed files with 388 additions and 84 deletions
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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[][]
|
||||||
|
|
|
||||||
1
main.go
1
main.go
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue