Implement priority token.
This commit is contained in:
+23
-3
@@ -195,6 +195,18 @@ func (g *Game) resolveBattle() {
|
|||||||
sides[p.Seat] = s
|
sides[p.Seat] = s
|
||||||
res.StackSizes[p.Seat] = len(p.Deck)
|
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
|
// Battle-prep effects that start apples in play (Monkey): they attach
|
||||||
// to the owner's first pet.
|
// to the owner's first pet.
|
||||||
for _, p := range g.Players {
|
for _, p := range g.Players {
|
||||||
@@ -399,7 +411,8 @@ func (g *Game) resolveBattle() {
|
|||||||
// effects queue up and resolve after all reveals (simultaneous).
|
// effects queue up and resolve after all reveals (simultaneous).
|
||||||
var plays []queuedPlay
|
var plays []queuedPlay
|
||||||
newlyPlayed := make([]bool, n)
|
newlyPlayed := make([]bool, n)
|
||||||
for seat, s := range sides {
|
for _, seat := range seatOrder {
|
||||||
|
s := sides[seat]
|
||||||
for s.unit == nil && len(s.stack) > 0 {
|
for s.unit == nil && len(s.stack) > 0 {
|
||||||
c := s.stack[0]
|
c := s.stack[0]
|
||||||
s.stack = s.stack[1:]
|
s.stack = s.stack[1:]
|
||||||
@@ -447,11 +460,12 @@ func (g *Game) resolveBattle() {
|
|||||||
}
|
}
|
||||||
// Cross-side play triggers: Rhino rocks anyone who just played;
|
// Cross-side play triggers: Rhino rocks anyone who just played;
|
||||||
// Crocodile volleys when the enemy plays their last pet.
|
// Crocodile volleys when the enemy plays their last pet.
|
||||||
for seat := range sides {
|
for _, seat := range seatOrder {
|
||||||
if !newlyPlayed[seat] {
|
if !newlyPlayed[seat] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for other, os := range sides {
|
for _, other := range seatOrder {
|
||||||
|
os := sides[other]
|
||||||
if other == seat {
|
if other == seat {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -644,6 +658,12 @@ func (g *Game) resolveBattle() {
|
|||||||
res.Trophies = 2
|
res.Trophies = 2
|
||||||
}
|
}
|
||||||
g.Players[winner].Trophies += res.Trophies
|
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
|
g.Battle = res
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ func testGame(t *testing.T) (*Game, *Player, *Player) {
|
|||||||
if g.Phase != PhaseShop {
|
if g.Phase != PhaseShop {
|
||||||
t.Fatalf("expected shop phase after both players join, got %s", g.Phase)
|
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
|
return g, p1, p2
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -620,3 +624,75 @@ func TestTemporariesExpireAfterBattle(t *testing.T) {
|
|||||||
t.Fatalf("pets should survive the round: %+v", p1.Deck)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -84,6 +84,11 @@ type Game struct {
|
|||||||
ShopDecks [][]Card `json:"shopDecks"` // index 0 = tier 1
|
ShopDecks [][]Card `json:"shopDecks"` // index 0 = tier 1
|
||||||
ShopRow []Card `json:"shopRow"` // empty ID = empty slot
|
ShopRow []Card `json:"shopRow"` // empty ID = empty slot
|
||||||
Turn int `json:"turn"` // seat with the current shop turn
|
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"`
|
Pending *PendingTrade `json:"pending,omitempty"`
|
||||||
Battle *BattleResult `json:"battle,omitempty"` // most recent battle
|
Battle *BattleResult `json:"battle,omitempty"` // most recent battle
|
||||||
NextCardID int `json:"nextCardId"`
|
NextCardID int `json:"nextCardId"`
|
||||||
@@ -199,6 +204,8 @@ func (g *Game) PlayerByID(id string) *Player {
|
|||||||
|
|
||||||
func (g *Game) start() {
|
func (g *Game) start() {
|
||||||
g.Round = 1
|
g.Round = 1
|
||||||
|
// The priority token starts with a random seat.
|
||||||
|
g.PrioritySeat = randInt(len(g.Players))
|
||||||
g.startShopRound()
|
g.startShopRound()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,7 +223,8 @@ func (g *Game) startShopRound() {
|
|||||||
for i := range g.ShopRow {
|
for i := range g.ShopRow {
|
||||||
g.ShopRow[i] = g.drawFromTier(g.Round)
|
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).
|
// drawFromTier pops the top card of the given tier's deck (1-based tier).
|
||||||
|
|||||||
@@ -393,12 +393,17 @@ func TestFullGameFlow(t *testing.T) {
|
|||||||
g, p1, p2 := testGame(t)
|
g, p1, p2 := testGame(t)
|
||||||
p1.Deck = append(p1.Deck, g.pet("Champ", 9))
|
p1.Deck = append(p1.Deck, g.pet("Champ", 9))
|
||||||
p2.Deck = append(p2.Deck, g.pet("Chump", 1))
|
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++ {
|
for round := 1; round <= MaxRounds; round++ {
|
||||||
if g.Round != round || g.Phase != PhaseShop {
|
if g.Round != round || g.Phase != PhaseShop {
|
||||||
t.Fatalf("expected shop of round %d, got round %d phase %s", round, g.Round, g.Phase)
|
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) {
|
if g.PrioritySeat != wantPriority || g.Turn != wantPriority {
|
||||||
t.Fatalf("round %d should rotate the starting player, turn=%d", round, g.Turn)
|
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 {
|
for _, c := range g.ShopRow {
|
||||||
if c.ID != "" && c.Tier != round {
|
if c.ID != "" && c.Tier != round {
|
||||||
@@ -417,6 +422,10 @@ func TestFullGameFlow(t *testing.T) {
|
|||||||
if g.Battle.WinnerSeat != p1.Seat {
|
if g.Battle.WinnerSeat != p1.Seat {
|
||||||
t.Fatalf("round %d: seat 0 should win", round)
|
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 {
|
for _, p := range g.Players {
|
||||||
if err := g.AcknowledgeBattle(p.ID); err != nil {
|
if err := g.AcknowledgeBattle(p.ID); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ type View struct {
|
|||||||
MaxPets int `json:"maxPets"`
|
MaxPets int `json:"maxPets"`
|
||||||
YouSeat int `json:"youSeat"`
|
YouSeat int `json:"youSeat"`
|
||||||
Turn int `json:"turn"`
|
Turn int `json:"turn"`
|
||||||
|
// PrioritySeat is the seat currently holding the priority token.
|
||||||
|
PrioritySeat int `json:"prioritySeat"`
|
||||||
ShopRow []Card `json:"shopRow"`
|
ShopRow []Card `json:"shopRow"`
|
||||||
DeckCounts []int `json:"deckCounts"` // remaining shop cards per tier
|
DeckCounts []int `json:"deckCounts"` // remaining shop cards per tier
|
||||||
Players []PlayerView `json:"players"`
|
Players []PlayerView `json:"players"`
|
||||||
@@ -46,6 +48,7 @@ func (g *Game) ViewFor(playerID string) View {
|
|||||||
MaxPets: MaxPets,
|
MaxPets: MaxPets,
|
||||||
YouSeat: -1,
|
YouSeat: -1,
|
||||||
Turn: g.Turn,
|
Turn: g.Turn,
|
||||||
|
PrioritySeat: g.PrioritySeat,
|
||||||
ShopRow: g.ShopRow,
|
ShopRow: g.ShopRow,
|
||||||
WinnerSeat: g.WinnerSeat,
|
WinnerSeat: g.WinnerSeat,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user