diff --git a/internal/game/battle.go b/internal/game/battle.go index af1bfa1..a2357a6 100644 --- a/internal/game/battle.go +++ b/internal/game/battle.go @@ -195,6 +195,18 @@ func (g *Game) resolveBattle() { sides[p.Seat] = s res.StackSizes[p.Seat] = len(p.Deck) } + // seatOrder resolves the priority-token holder first, then everyone else. + // Reveals, queued play effects, and cross-side triggers all follow it, so + // when two pets would act simultaneously (e.g. both throwing rocks) the + // holder acts first — its rocks can faint the enemy pet before that pet's + // own queued rocks resolve. + seatOrder := make([]int, 0, n) + seatOrder = append(seatOrder, g.PrioritySeat) + for seat := range sides { + if seat != g.PrioritySeat { + seatOrder = append(seatOrder, seat) + } + } // Battle-prep effects that start apples in play (Monkey): they attach // to the owner's first pet. for _, p := range g.Players { @@ -399,7 +411,8 @@ func (g *Game) resolveBattle() { // effects queue up and resolve after all reveals (simultaneous). var plays []queuedPlay newlyPlayed := make([]bool, n) - for seat, s := range sides { + for _, seat := range seatOrder { + s := sides[seat] for s.unit == nil && len(s.stack) > 0 { c := s.stack[0] s.stack = s.stack[1:] @@ -447,11 +460,12 @@ func (g *Game) resolveBattle() { } // Cross-side play triggers: Rhino rocks anyone who just played; // Crocodile volleys when the enemy plays their last pet. - for seat := range sides { + for _, seat := range seatOrder { if !newlyPlayed[seat] { continue } - for other, os := range sides { + for _, other := range seatOrder { + os := sides[other] if other == seat { continue } @@ -644,6 +658,12 @@ func (g *Game) resolveBattle() { res.Trophies = 2 } g.Players[winner].Trophies += res.Trophies + // Priority token: the winner hands it to the other player; a loser + // who held it keeps it; a draw leaves it put. (Two-player rule; the + // "other player" is unambiguous only at n == 2.) + if winner == g.PrioritySeat { + g.PrioritySeat = (winner + 1) % n + } } g.Battle = res diff --git a/internal/game/battle_test.go b/internal/game/battle_test.go index 95856f7..40bc2f2 100644 --- a/internal/game/battle_test.go +++ b/internal/game/battle_test.go @@ -20,6 +20,10 @@ func testGame(t *testing.T) (*Game, *Player, *Player) { if g.Phase != PhaseShop { t.Fatalf("expected shop phase after both players join, got %s", g.Phase) } + // Pin the (otherwise random) priority token to seat 0 so tests are + // deterministic: seat 0 shops first and wins battle simultaneity races. + g.PrioritySeat = p1.Seat + g.Turn = p1.Seat return g, p1, p2 } @@ -620,3 +624,75 @@ func TestTemporariesExpireAfterBattle(t *testing.T) { t.Fatalf("pets should survive the round: %+v", p1.Deck) } } + +// TestPriorityTokenInitialAssignment checks the token starts on a real seat +// and that seat opens the shop. +func TestPriorityTokenInitialAssignment(t *testing.T) { + g := New() + if _, err := g.AddPlayer("Alice"); err != nil { + t.Fatal(err) + } + if _, err := g.AddPlayer("Bob"); err != nil { + t.Fatal(err) + } + if g.PrioritySeat < 0 || g.PrioritySeat >= len(g.Players) { + t.Fatalf("priority token should start on a real seat, got %d", g.PrioritySeat) + } + if g.Turn != g.PrioritySeat { + t.Fatalf("priority holder should open the shop: turn=%d priority=%d", g.Turn, g.PrioritySeat) + } +} + +// TestPriorityWinsRockRace: two pets that both throw rocks on play would each +// kill the other, so whoever has priority throws first and survives. Flipping +// the token flips the winner, proving the token — not seat order — decides. +func TestPriorityWinsRockRace(t *testing.T) { + for _, priority := range []int{0, 1} { + g, _, _ := testGame(t) + g.RollDie = func() int { return 2 } // 3 dice = 6, lethal to a 2-power pet + g.PrioritySeat = priority + res := forceBattle(t, g, + []Card{g.realPet(t, "Dolphin")}, + []Card{g.realPet(t, "Dolphin")}) + if res.WinnerSeat != priority { + t.Fatalf("priority seat %d should win the rock race, winner=%d", priority, res.WinnerSeat) + } + } +} + +// TestPriorityTokenTransfer: a winner who holds the token hands it to the +// loser; a loser who holds it keeps it; a draw leaves it put. +func TestPriorityTokenTransfer(t *testing.T) { + // Winner holds it -> passes to the loser. + g, _, _ := testGame(t) + g.PrioritySeat = 0 + forceBattle(t, g, []Card{g.pet("Champ", 9)}, []Card{g.pet("Chump", 1)}) + if g.Battle.WinnerSeat != 0 { + t.Fatalf("seat 0 should win, got %d", g.Battle.WinnerSeat) + } + if g.PrioritySeat != 1 { + t.Fatalf("winner should hand the token to the loser, priority=%d", g.PrioritySeat) + } + + // Loser holds it -> keeps it. + g, _, _ = testGame(t) + g.PrioritySeat = 1 + forceBattle(t, g, []Card{g.pet("Champ", 9)}, []Card{g.pet("Chump", 1)}) + if g.Battle.WinnerSeat != 0 { + t.Fatalf("seat 0 should win, got %d", g.Battle.WinnerSeat) + } + if g.PrioritySeat != 1 { + t.Fatalf("a losing token holder should keep it, priority=%d", g.PrioritySeat) + } + + // Draw -> token stays put. Equal-power pets trade lethal blows at once. + g, _, _ = testGame(t) + g.PrioritySeat = 0 + forceBattle(t, g, []Card{g.pet("A", 3)}, []Card{g.pet("B", 3)}) + if g.Battle.WinnerSeat != -1 { + t.Fatalf("mutual KO should draw, got %d", g.Battle.WinnerSeat) + } + if g.PrioritySeat != 0 { + t.Fatalf("a draw should leave the token put, priority=%d", g.PrioritySeat) + } +} diff --git a/internal/game/game.go b/internal/game/game.go index a0862d2..8f993e0 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -76,18 +76,23 @@ type PendingTrade struct { // Game is the complete authoritative state. It is a pure state machine: no // goroutines, no clocks, no I/O. Callers are responsible for locking. type Game struct { - ID string `json:"id"` - Code string `json:"code"` - Phase Phase `json:"phase"` - Round int `json:"round"` // 1-based - Players []*Player `json:"players"` - ShopDecks [][]Card `json:"shopDecks"` // index 0 = tier 1 - ShopRow []Card `json:"shopRow"` // empty ID = empty slot - Turn int `json:"turn"` // seat with the current shop turn - Pending *PendingTrade `json:"pending,omitempty"` - Battle *BattleResult `json:"battle,omitempty"` // most recent battle - NextCardID int `json:"nextCardId"` - WinnerSeat int `json:"winnerSeat"` // set at gameover; -1 = tie + ID string `json:"id"` + Code string `json:"code"` + Phase Phase `json:"phase"` + Round int `json:"round"` // 1-based + Players []*Player `json:"players"` + ShopDecks [][]Card `json:"shopDecks"` // index 0 = tier 1 + ShopRow []Card `json:"shopRow"` // empty ID = empty slot + Turn int `json:"turn"` // seat with the current shop turn + // PrioritySeat holds the priority token: that seat shops first each round + // and wins simultaneity races in battle. Assigned randomly at game start; + // a battle winner hands it to the loser, a loser keeps it, a draw leaves + // it put. + PrioritySeat int `json:"prioritySeat"` + Pending *PendingTrade `json:"pending,omitempty"` + Battle *BattleResult `json:"battle,omitempty"` // most recent battle + NextCardID int `json:"nextCardId"` + WinnerSeat int `json:"winnerSeat"` // set at gameover; -1 = tie // RollDie overrides the rock die (faces 0,0,1,1,2,2) for tests. Nil // (including after loading from storage) means a fair random roll. @@ -199,6 +204,8 @@ func (g *Game) PlayerByID(id string) *Player { func (g *Game) start() { g.Round = 1 + // The priority token starts with a random seat. + g.PrioritySeat = randInt(len(g.Players)) g.startShopRound() } @@ -216,7 +223,8 @@ func (g *Game) startShopRound() { for i := range g.ShopRow { g.ShopRow[i] = g.drawFromTier(g.Round) } - g.Turn = (g.Round - 1) % len(g.Players) + // The priority-token holder shops first. + g.Turn = g.PrioritySeat } // drawFromTier pops the top card of the given tier's deck (1-based tier). diff --git a/internal/game/game_test.go b/internal/game/game_test.go index e1ecda4..d7d1b6c 100644 --- a/internal/game/game_test.go +++ b/internal/game/game_test.go @@ -393,12 +393,17 @@ func TestFullGameFlow(t *testing.T) { g, p1, p2 := testGame(t) p1.Deck = append(p1.Deck, g.pet("Champ", 9)) p2.Deck = append(p2.Deck, g.pet("Chump", 1)) + // testGame pins the priority token to seat 0, and seat 0 wins every + // battle, so the token moves to seat 1 after round 1 and stays there (a + // loser keeps it). Whoever holds it shops first. + wantPriority := p1.Seat for round := 1; round <= MaxRounds; round++ { if g.Round != round || g.Phase != PhaseShop { t.Fatalf("expected shop of round %d, got round %d phase %s", round, g.Round, g.Phase) } - if g.Turn != (round-1)%len(g.Players) { - t.Fatalf("round %d should rotate the starting player, turn=%d", round, g.Turn) + if g.PrioritySeat != wantPriority || g.Turn != wantPriority { + t.Fatalf("round %d: priority holder should shop first (want seat %d, priority=%d turn=%d)", + round, wantPriority, g.PrioritySeat, g.Turn) } for _, c := range g.ShopRow { if c.ID != "" && c.Tier != round { @@ -417,6 +422,10 @@ func TestFullGameFlow(t *testing.T) { if g.Battle.WinnerSeat != p1.Seat { t.Fatalf("round %d: seat 0 should win", round) } + // The winner hands the token to the loser; a loser keeps it. + if wantPriority == g.Battle.WinnerSeat { + wantPriority = (wantPriority + 1) % len(g.Players) + } for _, p := range g.Players { if err := g.AcknowledgeBattle(p.ID); err != nil { t.Fatal(err) diff --git a/internal/game/view.go b/internal/game/view.go index 854d008..f435616 100644 --- a/internal/game/view.go +++ b/internal/game/view.go @@ -17,17 +17,19 @@ type PlayerView struct { // View is the full game state as seen by one player. type View struct { - GameID string `json:"gameId"` - Code string `json:"code"` - Phase Phase `json:"phase"` - Round int `json:"round"` - MaxRounds int `json:"maxRounds"` - MaxPets int `json:"maxPets"` - YouSeat int `json:"youSeat"` - Turn int `json:"turn"` - ShopRow []Card `json:"shopRow"` - DeckCounts []int `json:"deckCounts"` // remaining shop cards per tier - Players []PlayerView `json:"players"` + GameID string `json:"gameId"` + Code string `json:"code"` + Phase Phase `json:"phase"` + Round int `json:"round"` + MaxRounds int `json:"maxRounds"` + MaxPets int `json:"maxPets"` + YouSeat int `json:"youSeat"` + Turn int `json:"turn"` + // PrioritySeat is the seat currently holding the priority token. + PrioritySeat int `json:"prioritySeat"` + ShopRow []Card `json:"shopRow"` + DeckCounts []int `json:"deckCounts"` // remaining shop cards per tier + Players []PlayerView `json:"players"` // Pending is included for everyone so opponents see a trade is in // progress, but the revealed options are only shown to the trader. Pending *PendingTrade `json:"pending,omitempty"` @@ -38,16 +40,17 @@ type View struct { // ViewFor builds the state visible to the given player. func (g *Game) ViewFor(playerID string) View { v := View{ - GameID: g.ID, - Code: g.Code, - Phase: g.Phase, - Round: g.Round, - MaxRounds: MaxRounds, - MaxPets: MaxPets, - YouSeat: -1, - Turn: g.Turn, - ShopRow: g.ShopRow, - WinnerSeat: g.WinnerSeat, + GameID: g.ID, + Code: g.Code, + Phase: g.Phase, + Round: g.Round, + MaxRounds: MaxRounds, + MaxPets: MaxPets, + YouSeat: -1, + Turn: g.Turn, + PrioritySeat: g.PrioritySeat, + ShopRow: g.ShopRow, + WinnerSeat: g.WinnerSeat, } for _, deck := range g.ShopDecks { v.DeckCounts = append(v.DeckCounts, len(deck)) diff --git a/internal/server/zz_e2e_test.go b/internal/server/zz_e2e_test.go new file mode 100644 index 0000000..cd309e5 --- /dev/null +++ b/internal/server/zz_e2e_test.go @@ -0,0 +1,125 @@ +package server + +import ( + "context" + "encoding/json" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/coder/websocket" + + "github.com/greyson/super-auto-pets-board-game/internal/game" + "github.com/greyson/super-auto-pets-board-game/internal/store" +) + +func readState(t *testing.T, ctx context.Context, ws *websocket.Conn) *game.View { + t.Helper() + for { + _, data, err := ws.Read(ctx) + if err != nil { + t.Fatalf("ws read: %v", err) + } + var m serverMessage + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if m.Type == "error" { + t.Logf("SERVER ERROR: %s", m.Error) + continue + } + if m.Type == "state" && m.State != nil { + return m.State + } + } +} + +func send(t *testing.T, ctx context.Context, ws *websocket.Conn, v any) { + t.Helper() + data, _ := json.Marshal(v) + if err := ws.Write(ctx, websocket.MessageText, data); err != nil { + t.Fatalf("ws write: %v", err) + } +} + +func TestE2EBattleAckReturnsToShop(t *testing.T) { + st, err := store.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer st.Close() + srv := New(st, "") + ts := httptest.NewServer(srv.Handler()) + defer ts.Close() + base := ts.URL + wsBase := "ws" + strings.TrimPrefix(base, "http") + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // Directly build a game via the room to control decks, then drive acks + // over the wire the way the two browsers do. + g := game.New() + p1, _ := g.AddPlayer("Alice") + p2, _ := g.AddPlayer("Bob") + r := &room{game: g, conns: map[*client]struct{}{}} + srv.rooms[g.ID] = r + if err := st.Save(g); err != nil { + t.Fatal(err) + } + + dialWS := func(pid, token string) *websocket.Conn { + u := wsBase + "/api/ws?game=" + g.ID + "&player=" + pid + "&token=" + token + c, _, err := websocket.Dial(ctx, u, nil) + if err != nil { + t.Fatalf("dial %s: %v", pid, err) + } + return c + } + ws1 := dialWS(p1.ID, p1.Token) + defer ws1.Close(websocket.StatusNormalClosure, "") + ws2 := dialWS(p2.ID, p2.Token) + defer ws2.Close(websocket.StatusNormalClosure, "") + + // Force a battle under the lock. + r.mu.Lock() + g.Players[0].Deck = []game.Card{{ID: "c1", Kind: game.KindPet, Name: "A", Tier: 1, Power: 3, Suit: game.SuitRed}} + g.Players[1].Deck = []game.Card{{ID: "c2", Kind: game.KindPet, Name: "B", Tier: 1, Power: 1, Suit: game.SuitRed}} + g.Phase = game.PhaseArrange + g.Players[0].Ready = false + g.Players[1].Ready = false + g.SubmitOrder(p1.ID, []string{g.Players[0].Deck[0].ID}) + g.SubmitOrder(p2.ID, []string{g.Players[1].Deck[0].ID}) + r.broadcastLocked() + r.mu.Unlock() + + readUntilPhase(t, ctx, ws1, game.PhaseBattle) + readUntilPhase(t, ctx, ws2, game.PhaseBattle) + + // Both acknowledge. + send(t, ctx, ws1, map[string]string{"type": "ready"}) + send(t, ctx, ws2, map[string]string{"type": "ready"}) + + // Each client should eventually observe the shop phase. + got1 := readUntilPhase(t, ctx, ws1, game.PhaseShop) + got2 := readUntilPhase(t, ctx, ws2, game.PhaseShop) + t.Logf("ws1 final phase=%s, ws2 final phase=%s", got1, got2) +} + +func readUntilPhase(t *testing.T, ctx context.Context, ws *websocket.Conn, want game.Phase) game.Phase { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + var last game.Phase + for time.Now().Before(deadline) { + rctx, cancel := context.WithTimeout(ctx, 2*time.Second) + v := readState(t, rctx, ws) + cancel() + last = v.Phase + if v.Phase == want { + return v.Phase + } + } + t.Fatalf("never reached %s; last=%s", want, last) + return last +}