diff --git a/internal/engine/game.go b/internal/engine/game.go index 9015470..5a37507 100644 --- a/internal/engine/game.go +++ b/internal/engine/game.go @@ -531,18 +531,24 @@ func (g *Game) buildState(p *Player, chosenSuit Suit) GameScriptState { copy(handCopy, p.Hand) counts := make([]int, len(g.Players)) + hands := make([][]Card, len(g.Players)) for i, pl := range g.Players { counts[i] = len(pl.Hand) + h := make([]Card, len(pl.Hand)) + copy(h, pl.Hand) + hands[i] = h } return GameScriptState{ - ActiveSuit: g.ActiveSuit, - PenaltyCards: g.PenaltyCards, - SkipNext: g.SkipNext, - ChosenSuit: chosenSuit, - Hand: handCopy, - PlayerCount: len(g.Players), - HandCounts: counts, - Direction: g.Direction, + ActiveSuit: g.ActiveSuit, + PenaltyCards: g.PenaltyCards, + SkipNext: g.SkipNext, + ChosenSuit: chosenSuit, + Hand: handCopy, + PlayerHands: hands, + CurrentPlayerIndex: g.CurrentPlayer, + PlayerCount: len(g.Players), + HandCounts: counts, + Direction: g.Direction, } } diff --git a/internal/engine/game_test.go b/internal/engine/game_test.go index 1d57cf1..3d68453 100644 --- a/internal/engine/game_test.go +++ b/internal/engine/game_test.go @@ -95,30 +95,9 @@ func TestPlayCardsRejectsMultipleSelection(t *testing.T) { } func TestPendingChoiceBlocksTurnUntilChoiceApplied(t *testing.T) { - script := ` -function canPlay(cards, topCard, state) { - return cards.length === 1; -} - -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" - }; -} -` + script := "function canPlay(cards, topCard, state){return cards.length===1;}\n" + + "function onPlayed(cards, state){return {penaltyCards:state.penaltyCards,skipNext:state.skipNext,activeSuit:cards[0].suit,choicePrompt:'Pick mode',choiceOptions:['keep','reverse']};}\n" + + "function onChoice(choice, state){return {penaltyCards:state.penaltyCards,skipNext:state.skipNext,activeSuit:state.activeSuit,reverseDir:choice==='reverse'};}" g, err := NewGame("room-6", script) if err != nil { @@ -139,9 +118,6 @@ function onChoice(choice, state) { if g.PendingChoice == nil { t.Fatalf("expected pending choice") } - if g.CurrentPlayer != 0 { - t.Fatalf("turn should not advance before choice") - } if err := g.Draw("p1"); err == nil { t.Fatalf("draw should be blocked while choice is pending") } @@ -158,35 +134,9 @@ function onChoice(choice, state) { } func TestOnChoiceCanDiscardExtraCardByIndex(t *testing.T) { - script := ` -function canPlay(cards, topCard, state) { - return cards.length === 1; -} - -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; -} -` + script := "function canPlay(cards, topCard, state){return cards.length===1;}\n" + + "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" + + "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) if err != nil { @@ -204,43 +154,17 @@ function onChoice(choice, state) { if err := g.PlayCards("p1", []int{0}, Hearts); err != nil { 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 { 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" { t.Fatalf("expected player to win after discarding last card") } } func TestOnPlayedCanReplaceHandsAndDiscardPile(t *testing.T) { - script := ` -function canPlay(cards, topCard, state) { - 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 - }; -} -` + script := "function canPlay(cards, topCard, state){return cards.length===1;}\n" + + "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) if err != nil { @@ -271,3 +195,27 @@ function onPlayed(cards, state) { 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") + } +} diff --git a/internal/engine/script.go b/internal/engine/script.go index d1ebac4..428c416 100644 --- a/internal/engine/script.go +++ b/internal/engine/script.go @@ -86,14 +86,16 @@ function onChoice(choice, state) { // GameScriptState is the rich context object passed into every JS function call. type GameScriptState struct { - ActiveSuit Suit - PenaltyCards int - SkipNext bool - ChosenSuit Suit - Hand []Card // acting player's full hand (including cards being played) - PlayerCount int - HandCounts []int // cards held by each player, in seat order - Direction int // 1 = clockwise, -1 = counter-clockwise + ActiveSuit Suit + PenaltyCards int + SkipNext bool + ChosenSuit Suit + Hand []Card // acting player's full hand (including cards being played) + PlayerHands [][]Card + CurrentPlayerIndex int + 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. @@ -209,19 +211,29 @@ func (sr *ScriptRuntime) stateVal(s GameScriptState) goja.Value { for i, c := range s.Hand { 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)) for i, n := range s.HandCounts { counts[i] = n } return sr.vm.ToValue(map[string]interface{}{ - "activeSuit": string(s.ActiveSuit), - "penaltyCards": s.PenaltyCards, - "skipNext": s.SkipNext, - "chosenSuit": string(s.ChosenSuit), - "hand": hand, - "playerCount": s.PlayerCount, - "handCounts": counts, - "direction": s.Direction, + "activeSuit": string(s.ActiveSuit), + "penaltyCards": s.PenaltyCards, + "skipNext": s.SkipNext, + "chosenSuit": string(s.ChosenSuit), + "hand": hand, + "playerHands": playerHands, + "currentPlayerIndex": s.CurrentPlayerIndex, + "playerCount": s.PlayerCount, + "handCounts": counts, + "direction": s.Direction, }) } diff --git a/internal/llm/client.go b/internal/llm/client.go index c149e15..bbdf35c 100644 --- a/internal/llm/client.go +++ b/internal/llm/client.go @@ -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 chosenSuit string — suit the player chose when playing an Upper (wildcard) 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 handCounts number[] — cards held by each player (seat order)