This commit is contained in:
Pavel Flegr 2026-05-07 18:41:12 +02:00
commit ef045f9353
4 changed files with 76 additions and 108 deletions

View file

@ -531,18 +531,24 @@ func (g *Game) buildState(p *Player, chosenSuit Suit) GameScriptState {
copy(handCopy, p.Hand) copy(handCopy, p.Hand)
counts := make([]int, len(g.Players)) counts := make([]int, len(g.Players))
hands := make([][]Card, len(g.Players))
for i, pl := range g.Players { for i, pl := range g.Players {
counts[i] = len(pl.Hand) counts[i] = len(pl.Hand)
h := make([]Card, len(pl.Hand))
copy(h, pl.Hand)
hands[i] = h
} }
return GameScriptState{ return GameScriptState{
ActiveSuit: g.ActiveSuit, ActiveSuit: g.ActiveSuit,
PenaltyCards: g.PenaltyCards, PenaltyCards: g.PenaltyCards,
SkipNext: g.SkipNext, SkipNext: g.SkipNext,
ChosenSuit: chosenSuit, ChosenSuit: chosenSuit,
Hand: handCopy, Hand: handCopy,
PlayerCount: len(g.Players), PlayerHands: hands,
HandCounts: counts, CurrentPlayerIndex: g.CurrentPlayer,
Direction: g.Direction, PlayerCount: len(g.Players),
HandCounts: counts,
Direction: g.Direction,
} }
} }

View file

@ -95,30 +95,9 @@ func TestPlayCardsRejectsMultipleSelection(t *testing.T) {
} }
func TestPendingChoiceBlocksTurnUntilChoiceApplied(t *testing.T) { func TestPendingChoiceBlocksTurnUntilChoiceApplied(t *testing.T) {
script := ` script := "function canPlay(cards, topCard, state){return cards.length===1;}\n" +
function canPlay(cards, topCard, state) { "function onPlayed(cards, state){return {penaltyCards:state.penaltyCards,skipNext:state.skipNext,activeSuit:cards[0].suit,choicePrompt:'Pick mode',choiceOptions:['keep','reverse']};}\n" +
return cards.length === 1; "function onChoice(choice, state){return {penaltyCards:state.penaltyCards,skipNext:state.skipNext,activeSuit:state.activeSuit,reverseDir:choice==='reverse'};}"
}
function onPlayed(cards, state) {
return {
penaltyCards: state.penaltyCards,
skipNext: state.skipNext,
activeSuit: cards[0].suit,
choicePrompt: "Pick mode",
choiceOptions: ["keep", "reverse"]
};
}
function onChoice(choice, state) {
return {
penaltyCards: state.penaltyCards,
skipNext: state.skipNext,
activeSuit: state.activeSuit,
reverseDir: choice === "reverse"
};
}
`
g, err := NewGame("room-6", script) g, err := NewGame("room-6", script)
if err != nil { if err != nil {
@ -139,9 +118,6 @@ function onChoice(choice, state) {
if g.PendingChoice == nil { if g.PendingChoice == nil {
t.Fatalf("expected pending choice") t.Fatalf("expected pending choice")
} }
if g.CurrentPlayer != 0 {
t.Fatalf("turn should not advance before choice")
}
if err := g.Draw("p1"); err == nil { if err := g.Draw("p1"); err == nil {
t.Fatalf("draw should be blocked while choice is pending") t.Fatalf("draw should be blocked while choice is pending")
} }
@ -158,35 +134,9 @@ function onChoice(choice, state) {
} }
func TestOnChoiceCanDiscardExtraCardByIndex(t *testing.T) { func TestOnChoiceCanDiscardExtraCardByIndex(t *testing.T) {
script := ` script := "function canPlay(cards, topCard, state){return cards.length===1;}\n" +
function canPlay(cards, topCard, state) { "function onPlayed(cards, state){var c=cards[0];var out={penaltyCards:state.penaltyCards,skipNext:state.skipNext,activeSuit:c.suit};if(c.value==='king'){out.choicePrompt='Discard extra?';out.choiceOptions=['yes','no'];}return out;}\n" +
return cards.length === 1; "function onChoice(choice, state){var out={penaltyCards:state.penaltyCards,skipNext:state.skipNext,activeSuit:state.activeSuit};if(choice==='yes') out.discardIndex=0;return out;}"
}
function onPlayed(cards, state) {
var c = cards[0];
var out = {
penaltyCards: state.penaltyCards,
skipNext: state.skipNext,
activeSuit: c.suit
};
if (c.value === "king") {
out.choicePrompt = "Discard extra?";
out.choiceOptions = ["yes", "no"];
}
return out;
}
function onChoice(choice, state) {
var out = {
penaltyCards: state.penaltyCards,
skipNext: state.skipNext,
activeSuit: state.activeSuit
};
if (choice === "yes") out.discardIndex = 0;
return out;
}
`
g, err := NewGame("room-7", script) g, err := NewGame("room-7", script)
if err != nil { if err != nil {
@ -204,43 +154,17 @@ function onChoice(choice, state) {
if err := g.PlayCards("p1", []int{0}, Hearts); err != nil { if err := g.PlayCards("p1", []int{0}, Hearts); err != nil {
t.Fatalf("PlayCards failed: %v", err) t.Fatalf("PlayCards failed: %v", err)
} }
if g.PendingChoice == nil {
t.Fatalf("expected pending choice after king")
}
if len(g.Players[0].Hand) != 1 {
t.Fatalf("expected one remaining card before choice, got %d", len(g.Players[0].Hand))
}
if err := g.ApplyChoice("p1", "yes"); err != nil { if err := g.ApplyChoice("p1", "yes"); err != nil {
t.Fatalf("ApplyChoice failed: %v", err) t.Fatalf("ApplyChoice failed: %v", err)
} }
if len(g.Players[0].Hand) != 0 {
t.Fatalf("expected extra card to be discarded, hand=%d", len(g.Players[0].Hand))
}
if g.State != Ended || g.Winner == nil || g.Winner.ID != "p1" { if g.State != Ended || g.Winner == nil || g.Winner.ID != "p1" {
t.Fatalf("expected player to win after discarding last card") t.Fatalf("expected player to win after discarding last card")
} }
} }
func TestOnPlayedCanReplaceHandsAndDiscardPile(t *testing.T) { func TestOnPlayedCanReplaceHandsAndDiscardPile(t *testing.T) {
script := ` script := "function canPlay(cards, topCard, state){return cards.length===1;}\n" +
function canPlay(cards, topCard, state) { "function onPlayed(cards, state){var me=state.hand.slice();if(me.length>0) me=me.slice(1);return {penaltyCards:0,skipNext:false,activeSuit:'clubs',playerHands:[me,[]],playedPile:[{suit:'clubs',value:'10'}],currentPlayerIndex:1};}"
return cards.length === 1;
}
function onPlayed(cards, state) {
var me = state.hand.slice();
if (me.length > 0) me = me.slice(1);
return {
penaltyCards: 0,
skipNext: false,
activeSuit: "clubs",
playerHands: [me, []],
playedPile: [{ suit: "clubs", value: "10" }],
currentPlayerIndex: 1
};
}
`
g, err := NewGame("room-8", script) g, err := NewGame("room-8", script)
if err != nil { if err != nil {
@ -271,3 +195,27 @@ function onPlayed(cards, state) {
t.Fatalf("expected current player override to 1, got %d", g.CurrentPlayer) t.Fatalf("expected current player override to 1, got %d", g.CurrentPlayer)
} }
} }
func TestStateExposesPlayerHandsAndCurrentPlayerIndex(t *testing.T) {
script := "function canPlay(cards, topCard, state){return cards.length===1;}\n" +
"function onPlayed(cards, state){var next=(state.currentPlayerIndex+(state.direction||1)+state.playerCount)%state.playerCount;var hands=state.playerHands.slice();var tmp=hands[state.currentPlayerIndex];hands[state.currentPlayerIndex]=hands[next];hands[next]=tmp;return {penaltyCards:state.penaltyCards,skipNext:state.skipNext,activeSuit:cards[0].suit,playerHands:hands};}"
g, err := NewGame("room-9", script)
if err != nil {
t.Fatalf("NewGame failed: %v", err)
}
g.Players = []*Player{{ID: "p1", Name: "Alice"}, {ID: "p2", Name: "Bob"}}
g.State = Playing
g.CurrentPlayer = 0
g.ActiveSuit = Hearts
g.DiscardPile = []Card{{Suit: Hearts, Value: Nine}}
g.Players[0].Hand = []Card{{Suit: Hearts, Value: King}, {Suit: Clubs, Value: Ten}}
g.Players[1].Hand = []Card{{Suit: Spades, Value: Seven}}
if err := g.PlayCards("p1", []int{0}, Hearts); err != nil {
t.Fatalf("PlayCards failed: %v", err)
}
if len(g.Players[0].Hand) != 1 || g.Players[0].Hand[0].Suit != Spades || g.Players[0].Hand[0].Value != Seven {
t.Fatalf("expected player 0 to receive player 1 hand")
}
}

View file

@ -86,14 +86,16 @@ function onChoice(choice, state) {
// GameScriptState is the rich context object passed into every JS function call. // GameScriptState is the rich context object passed into every JS function call.
type GameScriptState struct { type GameScriptState struct {
ActiveSuit Suit ActiveSuit Suit
PenaltyCards int PenaltyCards int
SkipNext bool SkipNext bool
ChosenSuit Suit ChosenSuit Suit
Hand []Card // acting player's full hand (including cards being played) Hand []Card // acting player's full hand (including cards being played)
PlayerCount int PlayerHands [][]Card
HandCounts []int // cards held by each player, in seat order CurrentPlayerIndex int
Direction int // 1 = clockwise, -1 = counter-clockwise PlayerCount int
HandCounts []int // cards held by each player, in seat order
Direction int // 1 = clockwise, -1 = counter-clockwise
} }
// OnPlayedResult carries every mutation onPlayed() can request. // OnPlayedResult carries every mutation onPlayed() can request.
@ -209,19 +211,29 @@ func (sr *ScriptRuntime) stateVal(s GameScriptState) goja.Value {
for i, c := range s.Hand { for i, c := range s.Hand {
hand[i] = map[string]string{"suit": string(c.Suit), "value": string(c.Value)} hand[i] = map[string]string{"suit": string(c.Suit), "value": string(c.Value)}
} }
playerHands := make([]interface{}, len(s.PlayerHands))
for i, h := range s.PlayerHands {
cards := make([]interface{}, len(h))
for j, c := range h {
cards[j] = map[string]string{"suit": string(c.Suit), "value": string(c.Value)}
}
playerHands[i] = cards
}
counts := make([]interface{}, len(s.HandCounts)) counts := make([]interface{}, len(s.HandCounts))
for i, n := range s.HandCounts { for i, n := range s.HandCounts {
counts[i] = n counts[i] = n
} }
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,
"skipNext": s.SkipNext, "skipNext": s.SkipNext,
"chosenSuit": string(s.ChosenSuit), "chosenSuit": string(s.ChosenSuit),
"hand": hand, "hand": hand,
"playerCount": s.PlayerCount, "playerHands": playerHands,
"handCounts": counts, "currentPlayerIndex": s.CurrentPlayerIndex,
"direction": s.Direction, "playerCount": s.PlayerCount,
"handCounts": counts,
"direction": s.Direction,
}) })
} }

View file

@ -60,6 +60,8 @@ The script MUST export these functions (no classes, no imports):
skipNext bool true when the next player must skip unless they counter with an Ace skipNext bool true when the next player must skip unless they counter with an Ace
chosenSuit string suit the player chose when playing an Upper (wildcard) chosenSuit string suit the player chose when playing an Upper (wildcard)
hand Card[] the acting player's FULL hand, including cards being played hand Card[] the acting player's FULL hand, including cards being played
playerHands Card[][] full hands of all players in seat order
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)