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

@ -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
}(),
}
}