Implement priority token.

This commit is contained in:
Greyson Parrelli
2026-07-23 00:45:25 -04:00
parent 1c457c602a
commit b8775432b4
6 changed files with 280 additions and 39 deletions
+23 -3
View File
@@ -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
+76
View File
@@ -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)
}
}
+21 -13
View File
@@ -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).
+11 -2
View File
@@ -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)
+24 -21
View File
@@ -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))