Add support for up to 6 players.
This commit is contained in:
+9
-2
@@ -247,11 +247,18 @@ func immediateWeight(v *game.View) float64 {
|
||||
if v.MaxRounds > 1 {
|
||||
w += 0.60 * float64(v.Round-1) / float64(v.MaxRounds-1)
|
||||
}
|
||||
me := v.Players[v.YouSeat]
|
||||
// How far behind the field the bot is. The yardstick is whoever is leading,
|
||||
// not the sum of everyone — at a six-player table the title is a race
|
||||
// against the front-runner, and summing would swamp the round term.
|
||||
me := v.PlayerView(v.YouSeat)
|
||||
best := 0
|
||||
for _, p := range v.Players {
|
||||
if p.Seat != v.YouSeat {
|
||||
w += 0.08 * float64(p.Trophies-me.Trophies)
|
||||
best = max(best, p.Trophies)
|
||||
}
|
||||
}
|
||||
if me != nil {
|
||||
w += 0.08 * float64(best-me.Trophies)
|
||||
}
|
||||
return min(max(w, 0.25), 1)
|
||||
}
|
||||
|
||||
+70
-21
@@ -32,24 +32,36 @@ func forcePlayable(id string) func() {
|
||||
|
||||
func playBotGamePack(t *testing.T, pack string, levelA, levelB float64) *game.Game {
|
||||
t.Helper()
|
||||
defer forcePlayable(pack)()
|
||||
return playBotTable(t, []string{pack}, levelA, levelB)
|
||||
}
|
||||
|
||||
// playBotTable drives a full game with a bot in every seat — one per level
|
||||
// given, so it covers tables of two, four, or six — the same way the server
|
||||
// would: observe on every state change, then act when input is owed. It fails
|
||||
// the test if a bot ever produces an illegal action or the game stops making
|
||||
// progress.
|
||||
func playBotTable(t *testing.T, packs []string, levels ...float64) *game.Game {
|
||||
t.Helper()
|
||||
for _, pack := range packs {
|
||||
defer forcePlayable(pack)()
|
||||
}
|
||||
g := game.New()
|
||||
pa, err := g.AddBot("Bot A", levelA)
|
||||
if err != nil {
|
||||
t.Fatalf("AddBot A: %v", err)
|
||||
bots := map[string]*Bot{}
|
||||
mems := map[string]*Memory{}
|
||||
for i, level := range levels {
|
||||
p, err := g.AddBot(fmt.Sprintf("Bot %c", 'A'+i), level)
|
||||
if err != nil {
|
||||
t.Fatalf("AddBot %d: %v", i, err)
|
||||
}
|
||||
bots[p.ID] = New(level)
|
||||
mems[p.ID] = &Memory{}
|
||||
}
|
||||
pb, err := g.AddBot("Bot B", levelB)
|
||||
if err != nil {
|
||||
t.Fatalf("AddBot B: %v", err)
|
||||
}
|
||||
if err := g.SetPack(pack); err != nil {
|
||||
t.Fatalf("SetPack %s: %v", pack, err)
|
||||
if err := g.SetPacks(packs); err != nil {
|
||||
t.Fatalf("SetPacks %v: %v", packs, err)
|
||||
}
|
||||
if err := g.StartGame(); err != nil {
|
||||
t.Fatalf("StartGame: %v", err)
|
||||
}
|
||||
bots := map[string]*Bot{pa.ID: New(levelA), pb.ID: New(levelB)}
|
||||
mems := map[string]*Memory{pa.ID: {}, pb.ID: {}}
|
||||
|
||||
observe := func() {
|
||||
for _, p := range g.Players {
|
||||
@@ -128,6 +140,43 @@ func TestBotsFinishGames(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBotsFinishMultiplayerGames plays complete four- and six-bot games on
|
||||
// combined packs — the multiplayer setup the rulebook calls for. Beyond the
|
||||
// usual "no illegal action" safety net, it exercises the bot against a rotating
|
||||
// opponent (a different rival every round), several battles resolving in one
|
||||
// round, and a card pool spanning more than one pack.
|
||||
func TestBotsFinishMultiplayerGames(t *testing.T) {
|
||||
tables := []struct {
|
||||
name string
|
||||
packs []string
|
||||
levels []float64
|
||||
}{
|
||||
{"4p", []string{"turtle", "golden"}, []float64{1, 0.6, 0.25, 0.6}},
|
||||
{"6p", []string{"turtle", "golden", "unicorn"}, []float64{1, 0.6, 0.25, 0, 1, 0.6}},
|
||||
}
|
||||
for _, tc := range tables {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
g := playBotTable(t, tc.packs, tc.levels...)
|
||||
if g.Round != game.MaxRounds {
|
||||
t.Errorf("game ended on round %d, want %d", g.Round, game.MaxRounds)
|
||||
}
|
||||
// Every seat should have fought all six rounds, so the trophies in
|
||||
// play must add up to the six battles per seat-pair.
|
||||
total := 0
|
||||
for _, p := range g.Players {
|
||||
total += p.Trophies
|
||||
}
|
||||
maxPossible := len(tc.levels) / 2 * (game.MaxRounds + 1) // round 6 pays double
|
||||
if total > maxPossible {
|
||||
t.Errorf("%d trophies awarded, only %d were available", total, maxPossible)
|
||||
}
|
||||
if len(g.WinnerSeats) == 0 {
|
||||
t.Error("a finished game should name at least one winner")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBotsFinishGoldenGame plays complete games on the Golden pack (tiers 1-3
|
||||
// printed; 4-6 empty). It exercises the Trumpet/Golden Retriever/Cone Snail
|
||||
// battle mechanics via SimulateBattle rollouts and the new shop effects, and
|
||||
@@ -233,11 +282,11 @@ func assertModelMatches(t *testing.T, mem *Memory, opp *game.Player) {
|
||||
want[c.Name]++
|
||||
}
|
||||
got := map[string]int{}
|
||||
for _, c := range mem.Opp.Known {
|
||||
for _, c := range mem.Opp(1).Known {
|
||||
got[c.Name]++
|
||||
}
|
||||
if len(mem.Opp.Hidden) != 0 {
|
||||
t.Errorf("model has %d hidden cards, want 0 (everything was public)", len(mem.Opp.Hidden))
|
||||
if len(mem.Opp(1).Hidden) != 0 {
|
||||
t.Errorf("model has %d hidden cards, want 0 (everything was public)", len(mem.Opp(1).Hidden))
|
||||
}
|
||||
for name, n := range want {
|
||||
if got[name] != n {
|
||||
@@ -306,8 +355,9 @@ func TestDecideShopNeverSellsLastPet(t *testing.T) {
|
||||
Round: game.MaxRounds, // alpha == 1: score is win-now only
|
||||
MaxRounds: game.MaxRounds,
|
||||
MaxPets: game.MaxPets,
|
||||
Pack: game.DefaultPack,
|
||||
Packs: []string{game.DefaultPack},
|
||||
YouSeat: 0,
|
||||
YourOpponent: 1,
|
||||
Turn: 0,
|
||||
PrioritySeat: 0,
|
||||
DeckCounts: make([]int, game.MaxRounds+1),
|
||||
@@ -319,9 +369,8 @@ func TestDecideShopNeverSellsLastPet(t *testing.T) {
|
||||
}
|
||||
// Model a crushing opponent so every simulated battle is a loss.
|
||||
mem := &Memory{}
|
||||
mem.Opp.Seat = 1
|
||||
for i := range 5 {
|
||||
mem.Opp.Known = append(mem.Opp.Known,
|
||||
mem.opp(1).Known = append(mem.opp(1).Known,
|
||||
game.Card{ID: fmt.Sprintf("o%d", i), Kind: game.KindPet, Name: "Wall", Tier: 1, Power: 50})
|
||||
}
|
||||
|
||||
@@ -360,8 +409,9 @@ func TestDecideShopKeepsHealthyBoard(t *testing.T) {
|
||||
Round: 3, // mid-game: future value still carries real weight
|
||||
MaxRounds: game.MaxRounds,
|
||||
MaxPets: game.MaxPets,
|
||||
Pack: game.DefaultPack,
|
||||
Packs: []string{game.DefaultPack},
|
||||
YouSeat: 0,
|
||||
YourOpponent: 1,
|
||||
Turn: 0,
|
||||
PrioritySeat: 0,
|
||||
DeckCounts: make([]int, game.MaxRounds+1),
|
||||
@@ -379,9 +429,8 @@ func TestDecideShopKeepsHealthyBoard(t *testing.T) {
|
||||
// A comparable opponent, so battles are genuinely competitive — selling is
|
||||
// a real temptation, not a hopeless-position tie-break.
|
||||
mem := &Memory{}
|
||||
mem.Opp.Seat = 1
|
||||
for i, n := range []string{"Dog", "Sheep", "Ant", "Cricket"} {
|
||||
mem.Opp.Known = append(mem.Opp.Known, pet(fmt.Sprintf("o%d", i), n, 3, 3, game.SuitRed))
|
||||
mem.opp(1).Known = append(mem.opp(1).Known, pet(fmt.Sprintf("o%d", i), n, 3, 3, game.SuitRed))
|
||||
}
|
||||
|
||||
bot := New(1.0) // a capable bot should almost never make this trade
|
||||
|
||||
+22
-9
@@ -36,9 +36,11 @@ func winScore(res *game.BattleResult, mySeat int) float64 {
|
||||
default:
|
||||
base = 0
|
||||
}
|
||||
// Survivors is indexed by battle side, not by seat.
|
||||
mySide := res.Side(mySeat)
|
||||
margin := 0
|
||||
for seat, s := range res.Survivors {
|
||||
if seat == mySeat {
|
||||
for side, s := range res.Survivors {
|
||||
if side == mySide {
|
||||
margin += s
|
||||
} else {
|
||||
margin -= s
|
||||
@@ -58,19 +60,30 @@ func (cx *ctx) winProb(myDeck []game.Card, oppDecks [][]game.Card, simsPer int)
|
||||
total, n := 0.0, 0
|
||||
for _, opp := range oppDecks {
|
||||
for range simsPer {
|
||||
var res *game.BattleResult
|
||||
if cx.me.Seat == 0 {
|
||||
res = game.SimulateBattle(cx.v.Round, cx.v.PrioritySeat, myDeck, opp, nil)
|
||||
} else {
|
||||
res = game.SimulateBattle(cx.v.Round, cx.v.PrioritySeat, opp, myDeck, nil)
|
||||
}
|
||||
total += winScore(res, cx.me.Seat)
|
||||
// The bot's own deck always takes scratch seat 0, so a rollout reads
|
||||
// the same however the real table happens to be seated.
|
||||
res := game.SimulateBattle(cx.v.Round, cx.simFirstSeat(), myDeck, opp, nil)
|
||||
total += winScore(res, 0)
|
||||
n++
|
||||
}
|
||||
}
|
||||
return total / float64(n)
|
||||
}
|
||||
|
||||
// simFirstSeat picks who acts first in a rollout, with the bot at seat 0. Two
|
||||
// players pass a priority token the bot can see, so it plans against the real
|
||||
// one; bigger tables flip a coin for each battle, which the bot can't know in
|
||||
// advance — it rolls too, and averages over both possibilities.
|
||||
func (cx *ctx) simFirstSeat() int {
|
||||
if len(cx.v.Players) == 2 {
|
||||
if cx.v.PrioritySeat == cx.me.Seat {
|
||||
return 0
|
||||
}
|
||||
return 1
|
||||
}
|
||||
return rand.IntN(2)
|
||||
}
|
||||
|
||||
// keepValue ranks a single card's worth to the bot's future: what it loses
|
||||
// by selling or trading it away. Temporary cards (apples) are nearly free to
|
||||
// lose — they vanish after the next battle anyway.
|
||||
|
||||
@@ -11,8 +11,11 @@ import (
|
||||
// to spare, a loss that took most of the enemy down), but no margin, however
|
||||
// lopsided, may ever raise a loss above a draw or a draw above a win.
|
||||
func TestWinScoreMarginBreaksTiesNotVerdicts(t *testing.T) {
|
||||
// Seat 0 fights seat 1, and is side 0 of the battle — Survivors and the
|
||||
// other per-side slices are indexed by side, so the mapping has to be there
|
||||
// for winScore to read the margin from the right end.
|
||||
mk := func(winner, mine, theirs int) *game.BattleResult {
|
||||
return &game.BattleResult{WinnerSeat: winner, Survivors: []int{mine, theirs}}
|
||||
return &game.BattleResult{WinnerSeat: winner, Seats: []int{0, 1}, Survivors: []int{mine, theirs}}
|
||||
}
|
||||
|
||||
decisiveWin := winScore(mk(0, 5, 0), 0)
|
||||
|
||||
+112
-58
@@ -17,7 +17,11 @@ type Memory struct {
|
||||
LastSeq int `json:"lastSeq"` // last event-log entry processed
|
||||
LastBattleRound int `json:"lastBattleRound"` // last battle lineup ingested
|
||||
PrevShopRow []game.Card `json:"prevShopRow"` // shop row at the previous observation
|
||||
Opp OppModel `json:"opp"`
|
||||
// Opps models every other seat at the table, keyed by seat. A bot fights a
|
||||
// different opponent each round (see the rulebook's pairings), and every
|
||||
// battle is played in the open, so it tracks the whole field rather than
|
||||
// one rival.
|
||||
Opps map[int]*OppModel `json:"opps,omitempty"`
|
||||
}
|
||||
|
||||
// OppModel is the bot's belief about one opponent's deck. Known holds cards
|
||||
@@ -30,6 +34,28 @@ type OppModel struct {
|
||||
Hidden []HiddenCard `json:"hidden,omitempty"`
|
||||
}
|
||||
|
||||
// opp returns the model for a seat, creating it on first sight.
|
||||
func (m *Memory) opp(seat int) *OppModel {
|
||||
if m.Opps == nil {
|
||||
m.Opps = map[int]*OppModel{}
|
||||
}
|
||||
o, ok := m.Opps[seat]
|
||||
if !ok {
|
||||
o = &OppModel{Seat: seat}
|
||||
m.Opps[seat] = o
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// Opp returns the bot's model of one seat. A seat it has never seen comes back
|
||||
// empty rather than nil, so callers can read it unconditionally.
|
||||
func (m *Memory) Opp(seat int) *OppModel {
|
||||
if o, ok := m.Opps[seat]; ok {
|
||||
return o
|
||||
}
|
||||
return &OppModel{Seat: seat}
|
||||
}
|
||||
|
||||
// HiddenCard is a card the opponent holds that the bot has not seen. Name is
|
||||
// set when the card was later named publicly (e.g. a trade pick revealed by
|
||||
// its buy ability) — the suit still isn't known, but the stats are.
|
||||
@@ -44,6 +70,14 @@ func LoadMemory(raw json.RawMessage) *Memory {
|
||||
m := &Memory{}
|
||||
if len(raw) > 0 {
|
||||
_ = json.Unmarshal(raw, m)
|
||||
// Notebooks written before the bot tracked a whole field held a single
|
||||
// "opp"; file it under its seat.
|
||||
var legacy struct {
|
||||
Opp *OppModel `json:"opp"`
|
||||
}
|
||||
if json.Unmarshal(raw, &legacy) == nil && legacy.Opp != nil && len(m.Opps) == 0 {
|
||||
m.Opps = map[int]*OppModel{legacy.Opp.Seat: legacy.Opp}
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -64,112 +98,132 @@ func (m *Memory) Marshal() json.RawMessage {
|
||||
// 1. new event-log entries, whose structured tags describe opponent shop
|
||||
// actions (buys name the card, sells name what left, trades list the
|
||||
// discarded trio, spawn entries count apples gained);
|
||||
// 2. the latest battle's lineups, which reveal both decks in full and reset
|
||||
// the model to ground truth every round (so any drift lasts one round);
|
||||
// 3. the opponent's public deck size, as a reconciliation safety net.
|
||||
// 2. the round's battle lineups, which reveal every deck in full and reset
|
||||
// the models to ground truth every round (so any drift lasts one round);
|
||||
// 3. each opponent's public deck size, as a reconciliation safety net.
|
||||
//
|
||||
// Every source is table-wide: the bot follows all its rivals, not only the one
|
||||
// it happens to be paired against, because it will face each of them later.
|
||||
func Observe(v *game.View, m *Memory) {
|
||||
if v.YouSeat < 0 {
|
||||
return
|
||||
}
|
||||
oppSeat := -1
|
||||
for _, p := range v.Players {
|
||||
if p.Seat != v.YouSeat {
|
||||
oppSeat = p.Seat
|
||||
break
|
||||
}
|
||||
isOpponent := func(seat int) bool {
|
||||
return seat >= 0 && seat != v.YouSeat && v.PlayerView(seat) != nil
|
||||
}
|
||||
if oppSeat < 0 {
|
||||
return
|
||||
}
|
||||
m.Opp.Seat = oppSeat
|
||||
|
||||
for _, e := range v.Log {
|
||||
if e.Seq <= m.LastSeq {
|
||||
continue
|
||||
}
|
||||
m.LastSeq = e.Seq
|
||||
if e.Seat != oppSeat {
|
||||
if !isOpponent(e.Seat) {
|
||||
continue
|
||||
}
|
||||
opp := m.opp(e.Seat)
|
||||
switch {
|
||||
case e.Kind == game.LogBuy:
|
||||
if c, ok := cardByID(m.PrevShopRow, e.Source); ok {
|
||||
// An Avocado buy is set aside, not kept in the deck (Golden
|
||||
// pack): don't add it to the deck model.
|
||||
if c.Food != game.FoodAvocado {
|
||||
m.Opp.Known = append(m.Opp.Known, c)
|
||||
opp.Known = append(opp.Known, c)
|
||||
}
|
||||
} else if c, ok := templateByName(v.Pack, e.CardName); ok {
|
||||
} else if c, ok := templateByName(v.Packs, e.CardName); ok {
|
||||
if c.Food != game.FoodAvocado {
|
||||
m.Opp.Known = append(m.Opp.Known, c)
|
||||
opp.Known = append(opp.Known, c)
|
||||
}
|
||||
}
|
||||
case e.Kind == game.LogSell:
|
||||
m.removeOppCard(e.Source, e.CardName)
|
||||
m.Opp.Known = append(m.Opp.Known, memApple(len(m.Opp.Known)))
|
||||
opp.remove(e.Source, e.CardName)
|
||||
opp.Known = append(opp.Known, memApple(len(opp.Known)))
|
||||
case e.Kind == game.LogTrade:
|
||||
for _, id := range e.Cards {
|
||||
m.removeOppCard(id, "")
|
||||
opp.remove(id, "")
|
||||
}
|
||||
case e.Kind == game.LogTradePick:
|
||||
m.Opp.Hidden = append(m.Opp.Hidden,
|
||||
opp.Hidden = append(opp.Hidden,
|
||||
HiddenCard{Tier: min(e.Round+1, game.MaxRounds), Name: e.CardName})
|
||||
case e.Spawn == "apple" && e.Kind == "":
|
||||
n := max(e.Count, 1)
|
||||
for range n {
|
||||
m.Opp.Known = append(m.Opp.Known, memApple(len(m.Opp.Known)))
|
||||
opp.Known = append(opp.Known, memApple(len(opp.Known)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Battle lineups are ground truth: rebuild the model from the opponent's
|
||||
// revealed deck, minus temporary cards (they expire with the battle).
|
||||
if v.Battle != nil && v.Battle.Round > m.LastBattleRound && oppSeat < len(v.Battle.Lineups) {
|
||||
m.LastBattleRound = v.Battle.Round
|
||||
m.Opp.Known = m.Opp.Known[:0]
|
||||
m.Opp.Hidden = nil
|
||||
for _, c := range v.Battle.Lineups[oppSeat] {
|
||||
if !c.Temporary {
|
||||
m.Opp.Known = append(m.Opp.Known, c)
|
||||
// Battle lineups are ground truth: rebuild each opponent's model from their
|
||||
// revealed deck, minus temporary cards (they expire with the battle). Every
|
||||
// table's battle is public, so one round refreshes the whole field.
|
||||
for _, b := range v.Battles {
|
||||
if b == nil || b.Round <= m.LastBattleRound {
|
||||
continue
|
||||
}
|
||||
for side, seat := range b.Seats {
|
||||
if !isOpponent(seat) || side >= len(b.Lineups) {
|
||||
continue
|
||||
}
|
||||
opp := m.opp(seat)
|
||||
opp.Known = opp.Known[:0]
|
||||
opp.Hidden = nil
|
||||
for _, c := range b.Lineups[side] {
|
||||
if !c.Temporary {
|
||||
opp.Known = append(opp.Known, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, b := range v.Battles {
|
||||
if b != nil {
|
||||
m.LastBattleRound = max(m.LastBattleRound, b.Round)
|
||||
}
|
||||
}
|
||||
|
||||
// Reconcile with the public deck size. Skipped during the battle phase,
|
||||
// where the live deck still holds temporaries the model excludes.
|
||||
// Reconcile with the public deck sizes. Skipped during the battle phase,
|
||||
// where the live decks still hold temporaries the models exclude.
|
||||
if v.Phase == game.PhaseShop || v.Phase == game.PhaseArrange {
|
||||
size := v.Players[slices.IndexFunc(v.Players, func(p game.PlayerView) bool { return p.Seat == oppSeat })].DeckSize
|
||||
for len(m.Opp.Known)+len(m.Opp.Hidden) < size {
|
||||
m.Opp.Hidden = append(m.Opp.Hidden, HiddenCard{Tier: v.Round})
|
||||
}
|
||||
for len(m.Opp.Known)+len(m.Opp.Hidden) > size {
|
||||
if len(m.Opp.Hidden) > 0 {
|
||||
m.Opp.Hidden = m.Opp.Hidden[:len(m.Opp.Hidden)-1]
|
||||
} else {
|
||||
m.Opp.Known = m.Opp.Known[:len(m.Opp.Known)-1]
|
||||
for _, p := range v.Players {
|
||||
if !isOpponent(p.Seat) {
|
||||
continue
|
||||
}
|
||||
m.opp(p.Seat).reconcile(p.DeckSize, v.Round)
|
||||
}
|
||||
}
|
||||
|
||||
m.PrevShopRow = append(m.PrevShopRow[:0], v.ShopRow...)
|
||||
}
|
||||
|
||||
// removeOppCard drops one card from the model: by exact ID when we tracked
|
||||
// it, by name as a fallback (model-minted apples have synthetic IDs), and
|
||||
// failing both, one hidden card — something we didn't know they had left.
|
||||
func (m *Memory) removeOppCard(id, name string) {
|
||||
if i := slices.IndexFunc(m.Opp.Known, func(c game.Card) bool { return c.ID == id }); i >= 0 {
|
||||
m.Opp.Known = slices.Delete(m.Opp.Known, i, i+1)
|
||||
// remove drops one card from the model: by exact ID when we tracked it, by
|
||||
// name as a fallback (model-minted apples have synthetic IDs), and failing
|
||||
// both, one hidden card — something we didn't know they had, now gone.
|
||||
func (o *OppModel) remove(id, name string) {
|
||||
if i := slices.IndexFunc(o.Known, func(c game.Card) bool { return c.ID == id }); i >= 0 {
|
||||
o.Known = slices.Delete(o.Known, i, i+1)
|
||||
return
|
||||
}
|
||||
if name != "" {
|
||||
if i := slices.IndexFunc(m.Opp.Known, func(c game.Card) bool { return c.Name == name }); i >= 0 {
|
||||
m.Opp.Known = slices.Delete(m.Opp.Known, i, i+1)
|
||||
if i := slices.IndexFunc(o.Known, func(c game.Card) bool { return c.Name == name }); i >= 0 {
|
||||
o.Known = slices.Delete(o.Known, i, i+1)
|
||||
return
|
||||
}
|
||||
}
|
||||
if len(m.Opp.Hidden) > 0 {
|
||||
m.Opp.Hidden = m.Opp.Hidden[:len(m.Opp.Hidden)-1]
|
||||
if len(o.Hidden) > 0 {
|
||||
o.Hidden = o.Hidden[:len(o.Hidden)-1]
|
||||
}
|
||||
}
|
||||
|
||||
// reconcile forces the model to hold exactly size cards, the count everyone can
|
||||
// see, padding with unknowns of the current tier or dropping the excess.
|
||||
func (o *OppModel) reconcile(size, round int) {
|
||||
for len(o.Known)+len(o.Hidden) < size {
|
||||
o.Hidden = append(o.Hidden, HiddenCard{Tier: round})
|
||||
}
|
||||
for len(o.Known)+len(o.Hidden) > size {
|
||||
if len(o.Hidden) > 0 {
|
||||
o.Hidden = o.Hidden[:len(o.Hidden)-1]
|
||||
} else {
|
||||
o.Known = o.Known[:len(o.Known)-1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,15 +239,15 @@ func cardByID(cards []game.Card, id string) (game.Card, bool) {
|
||||
return game.Card{}, false
|
||||
}
|
||||
|
||||
// templateByName mints a reference copy of a named card from the pack's
|
||||
// printed tier contents. The suit is whatever the first printed copy has —
|
||||
// callers only rely on stats and effects.
|
||||
func templateByName(pack, name string) (game.Card, bool) {
|
||||
// templateByName mints a reference copy of a named card from the printed tier
|
||||
// contents of the packs in play. The suit is whatever the first printed copy
|
||||
// has — callers only rely on stats and effects.
|
||||
func templateByName(packs []string, name string) (game.Card, bool) {
|
||||
if name == "" {
|
||||
return game.Card{}, false
|
||||
}
|
||||
for tier := 1; tier <= game.MaxRounds; tier++ {
|
||||
for _, c := range game.TierContentsForPack(pack, tier) {
|
||||
for _, c := range game.TierContentsForPacks(packs, tier) {
|
||||
if c.Name == name {
|
||||
return c, true
|
||||
}
|
||||
|
||||
+21
-10
@@ -19,27 +19,35 @@ type ctx struct {
|
||||
}
|
||||
|
||||
func newCtx(v *game.View, m *Memory) *ctx {
|
||||
cx := &ctx{v: v, m: m, me: &v.Players[v.YouSeat], oppSeat: m.Opp.Seat, pools: map[int][]game.Card{}}
|
||||
cx := &ctx{v: v, m: m, me: v.PlayerView(v.YouSeat), oppSeat: v.YourOpponent, pools: map[int][]game.Card{}}
|
||||
// The round's pairing says exactly who the bot is preparing for, so it
|
||||
// plans against that one rival even at a six-player table. Falling back to
|
||||
// any other seat keeps a malformed view from wedging the bot.
|
||||
if cx.oppSeat == cx.me.Seat || cx.oppSeat < 0 {
|
||||
// Memory hasn't observed yet (shouldn't happen in practice).
|
||||
for _, p := range v.Players {
|
||||
if p.Seat != v.YouSeat {
|
||||
cx.oppSeat = p.Seat
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return cx
|
||||
}
|
||||
|
||||
// opp is the model of the opponent this round's battle is against.
|
||||
func (cx *ctx) opp() *OppModel { return cx.m.Opp(cx.oppSeat) }
|
||||
|
||||
func (cx *ctx) nextSimID() string {
|
||||
cx.simID++
|
||||
return fmt.Sprintf("sim-%d", cx.simID)
|
||||
}
|
||||
|
||||
// unseenPool lists the printed cards of a tier that the bot cannot account
|
||||
// for anywhere it can see — its own deck, the opponent model, the shop row.
|
||||
// for anywhere it can see — its own deck, every opponent model, the shop row.
|
||||
// Hidden opponent cards are drawn from this pool, so the bot's guesses
|
||||
// respect card counting without peeking at the real decks.
|
||||
// respect card counting without peeking at the real decks. It counts against
|
||||
// the whole table's known cards, and against the combined contents of every
|
||||
// pack in play, which is what a human counting cards would be working from.
|
||||
func (cx *ctx) unseenPool(tier int) []game.Card {
|
||||
if pool, ok := cx.pools[tier]; ok {
|
||||
return pool
|
||||
@@ -53,14 +61,16 @@ func (cx *ctx) unseenPool(tier int) []game.Card {
|
||||
for _, c := range cx.me.Deck {
|
||||
note(c)
|
||||
}
|
||||
for _, c := range cx.m.Opp.Known {
|
||||
note(c)
|
||||
for _, opp := range cx.m.Opps {
|
||||
for _, c := range opp.Known {
|
||||
note(c)
|
||||
}
|
||||
}
|
||||
for _, c := range cx.v.ShopRow {
|
||||
note(c)
|
||||
}
|
||||
var pool []game.Card
|
||||
for _, c := range game.TierContentsForPack(cx.v.Pack, tier) {
|
||||
for _, c := range game.TierContentsForPacks(cx.v.Packs, tier) {
|
||||
if seen[c.Name] > 0 {
|
||||
seen[c.Name]--
|
||||
continue
|
||||
@@ -75,10 +85,11 @@ func (cx *ctx) unseenPool(tier int) []game.Card {
|
||||
// known cards as-is, hidden cards drawn from the unseen pool of their tier
|
||||
// (or their named template, when a pick was later revealed).
|
||||
func (cx *ctx) sampleOppDeck() []game.Card {
|
||||
deck := append([]game.Card(nil), cx.m.Opp.Known...)
|
||||
for _, h := range cx.m.Opp.Hidden {
|
||||
opp := cx.opp()
|
||||
deck := append([]game.Card(nil), opp.Known...)
|
||||
for _, h := range opp.Hidden {
|
||||
var c game.Card
|
||||
if t, ok := templateByName(cx.v.Pack, h.Name); ok {
|
||||
if t, ok := templateByName(cx.v.Packs, h.Name); ok {
|
||||
c = t
|
||||
} else if pool := cx.unseenPool(h.Tier); len(pool) > 0 {
|
||||
c = pool[rand.IntN(len(pool))]
|
||||
|
||||
+3
-4
@@ -88,7 +88,7 @@ func (cx *ctx) applyTemplateShopEffects(deck []game.Card, c game.Card, trigger g
|
||||
// with a sampled card of the current tier.
|
||||
pool := cx.unseenPool(cx.v.Round)
|
||||
if len(pool) == 0 {
|
||||
pool = game.TierContentsForPack(cx.v.Pack, cx.v.Round)
|
||||
pool = game.TierContentsForPacks(cx.v.Packs, cx.v.Round)
|
||||
}
|
||||
if len(pool) > 0 {
|
||||
rc := pool[rand.IntN(len(pool))]
|
||||
@@ -126,7 +126,7 @@ func (cx *ctx) applyTemplateShopEffects(deck []game.Card, c game.Card, trigger g
|
||||
deck = slices.Delete(deck, worst, worst+1)
|
||||
pool := cx.unseenPool(nextTier)
|
||||
if len(pool) == 0 {
|
||||
pool = game.TierContentsForPack(cx.v.Pack, nextTier)
|
||||
pool = game.TierContentsForPacks(cx.v.Packs, nextTier)
|
||||
}
|
||||
if len(pool) > 0 {
|
||||
rc := pool[rand.IntN(len(pool))]
|
||||
@@ -370,7 +370,7 @@ func (b *Bot) decideShop(v *game.View, mem *Memory) *Action {
|
||||
}
|
||||
pool := cx.unseenPool(v.Round + 1)
|
||||
if len(pool) == 0 {
|
||||
pool = game.TierContentsForPack(v.Pack, v.Round+1)
|
||||
pool = game.TierContentsForPacks(v.Packs, v.Round+1)
|
||||
}
|
||||
var decks [][]game.Card
|
||||
for range 3 {
|
||||
@@ -409,4 +409,3 @@ func (b *Bot) decideTradeChoose(v *game.View, mem *Memory) *Action {
|
||||
b.score(cx, cands)
|
||||
return b.pick(cands).act
|
||||
}
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@ func playHeadToHead(t *testing.T, pack string, level0, level1 float64) int {
|
||||
g := game.New()
|
||||
pa, _ := g.AddBot("Bot A", level0)
|
||||
pb, _ := g.AddBot("Bot B", level1)
|
||||
if err := g.SetPack(pack); err != nil {
|
||||
t.Fatalf("SetPack: %v", err)
|
||||
if err := g.SetPacks([]string{pack}); err != nil {
|
||||
t.Fatalf("SetPacks: %v", err)
|
||||
}
|
||||
if err := g.StartGame(); err != nil {
|
||||
t.Fatalf("StartGame: %v", err)
|
||||
|
||||
+299
-231
@@ -2,6 +2,7 @@ package game
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// BattleUnit is a pet in play with its attached foods applied. Power
|
||||
@@ -23,8 +24,8 @@ type BattleUnit struct {
|
||||
// Ailments (Unicorn pack) are debuffs on this pet: Spooked lowers the
|
||||
// damage it deals in a clash (min 0); Exposed raises the damage it takes on
|
||||
// each hit. bakuGuard, when set, discards the first Ailment it would gain.
|
||||
Spooked int `json:"spooked,omitempty"`
|
||||
Exposed int `json:"exposed,omitempty"`
|
||||
Spooked int `json:"spooked,omitempty"`
|
||||
Exposed int `json:"exposed,omitempty"`
|
||||
bakuGuard bool
|
||||
}
|
||||
|
||||
@@ -179,29 +180,57 @@ type BattleEvent struct {
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
// BattleResult is the full, public record of one round's battle.
|
||||
// BattleResult is the full, public record of one battle.
|
||||
//
|
||||
// A round runs one battle per pairing (see schedule.go), so a six-player round
|
||||
// produces three of these. Everything inside a result is indexed by *side* —
|
||||
// 0 or 1 within this battle — not by the player's seat at the table: Seats maps
|
||||
// the two apart, and BattleEvent.Seat/Target are side indices too. WinnerSeat
|
||||
// is the exception, and is a real seat, because it's the one field that means
|
||||
// something outside the battle.
|
||||
type BattleResult struct {
|
||||
Round int `json:"round"`
|
||||
StackSizes []int `json:"stackSizes"` // starting deck size per seat
|
||||
// Lineups is each seat's arranged deck at battle start (top of deck
|
||||
Round int `json:"round"`
|
||||
// Seats are the two players fighting, in first-player order: Seats[0] holds
|
||||
// priority and acts first when two effects would land simultaneously.
|
||||
Seats []int `json:"seats"`
|
||||
StackSizes []int `json:"stackSizes"` // starting deck size per side
|
||||
// Lineups is each side's arranged deck at battle start (top of deck
|
||||
// first). Public so players can review the whole matchup — including the
|
||||
// opponent's cards — during and after the fight.
|
||||
Lineups [][]Card `json:"lineups,omitempty"`
|
||||
Events []BattleEvent `json:"events"`
|
||||
WinnerSeat int `json:"winnerSeat"` // -1 = draw
|
||||
WinnerSeat int `json:"winnerSeat"` // a seat at the table; -1 = draw
|
||||
Trophies int `json:"trophies"` // awarded to the winner
|
||||
// Survivors is each seat's remaining force at battle end: pets still in
|
||||
// Survivors is each side's remaining force at battle end: pets still in
|
||||
// play plus any never reached in the stack. The loser is 0. It measures how
|
||||
// decisive the result was — the margin the AI uses to prefer a lineup that
|
||||
// fights harder, even in a battle it can't win.
|
||||
Survivors []int `json:"survivors,omitempty"`
|
||||
// ManaAfter (Unicorn pack) is each seat's persistent Mana pool once the
|
||||
// ManaAfter (Unicorn pack) is each side's persistent Mana pool once the
|
||||
// battle ends; finalizeBattle writes it back to the players. NextRoundApples
|
||||
// is apples each seat banked for next round's hand (Skeleton Dog).
|
||||
// is apples each side banked for next round's hand (Skeleton Dog).
|
||||
ManaAfter []int `json:"manaAfter,omitempty"`
|
||||
NextRoundApples []int `json:"nextRoundApples,omitempty"`
|
||||
}
|
||||
|
||||
// Side returns the battle-side index (0 or 1) for a seat at the table, or -1
|
||||
// if that player wasn't in this battle. Use it to read any of the per-side
|
||||
// slices above from a seat.
|
||||
func (r *BattleResult) Side(seat int) int {
|
||||
return slices.Index(r.Seats, seat)
|
||||
}
|
||||
|
||||
// Has reports whether a seat fought in this battle.
|
||||
func (r *BattleResult) Has(seat int) bool { return r.Side(seat) >= 0 }
|
||||
|
||||
// SeatOf returns the seat holding a side of this battle, or -1.
|
||||
func (r *BattleResult) SeatOf(side int) int {
|
||||
if side < 0 || side >= len(r.Seats) {
|
||||
return -1
|
||||
}
|
||||
return r.Seats[side]
|
||||
}
|
||||
|
||||
// setAsideRocks is a fainted pet's pending rock payout.
|
||||
type setAsideRocks struct {
|
||||
dice int
|
||||
@@ -240,11 +269,11 @@ type battleSide struct {
|
||||
shieldCards []Card // Turtle set-aside cards, parallel to shields
|
||||
|
||||
// --- Golden pack ---
|
||||
trumpets int // ephemeral Trumpet pool (earned/spent in battle)
|
||||
faintedHats map[Suit]bool // distinct suits among friendly fainted pets (Honduran White Bat)
|
||||
grSummoned bool // Golden Retriever already summoned this battle
|
||||
hitPrevent []int // Cone Snail: pending one-shot partial damage preventions
|
||||
preventCards []Card // Cone Snail set-aside cards, parallel to hitPrevent
|
||||
trumpets int // ephemeral Trumpet pool (earned/spent in battle)
|
||||
faintedHats map[Suit]bool // distinct suits among friendly fainted pets (Honduran White Bat)
|
||||
grSummoned bool // Golden Retriever already summoned this battle
|
||||
hitPrevent []int // Cone Snail: pending one-shot partial damage preventions
|
||||
preventCards []Card // Cone Snail set-aside cards, parallel to hitPrevent
|
||||
beePlayRocks []setAsideRocks // Poison Dart Frog: rocks each time a Bee is played
|
||||
feedOnPlay []feedAside // Giant Isopod: feed apples on each pet played
|
||||
petsPlayed int // pets fielded so far (Komodo's "first pet")
|
||||
@@ -324,10 +353,10 @@ func effectCount(e Effect, s *battleSide, u *BattleUnit, enemy *battleSide) int
|
||||
return n
|
||||
}
|
||||
|
||||
// resolveBattle simulates the battle from the players' arranged decks,
|
||||
// records the event log, awards trophies, and moves to PhaseBattle.
|
||||
// resolveBattles fights every pairing of the current round, records the event
|
||||
// logs, and awards trophies.
|
||||
//
|
||||
// The battle is a stack machine: each side reveals cards off the top of
|
||||
// Each battle is a stack machine: each side reveals cards off the top of
|
||||
// their deck until a pet is in play (foods along the way attach to it; only
|
||||
// the last-applied perk counts). If anyone can no longer field a pet the
|
||||
// battle ends. Otherwise play effects resolve (rocks, strips, steals,
|
||||
@@ -339,83 +368,119 @@ func effectCount(e Effect, s *battleSide, u *BattleUnit, enemy *battleSide) int
|
||||
// attack manages to hurt. A clash that changes nothing ends the battle as a
|
||||
// stalemate.
|
||||
//
|
||||
// resolveBattle is the orchestrator: it runs the (deterministic) simulation and
|
||||
// publishes the completed result.
|
||||
func (g *Game) resolveBattle() {
|
||||
res := g.runBattle()
|
||||
g.Battle = res
|
||||
g.finalizeBattle(res)
|
||||
}
|
||||
|
||||
// finalizeBattle applies the persistent effects of a completed battle: trophies,
|
||||
// the priority token hand-off, the result log line, and clearing the per-round
|
||||
// apples-in-play bank. Kept separate from runBattle, which mutates no persistent
|
||||
// player state.
|
||||
func (g *Game) finalizeBattle(res *BattleResult) {
|
||||
n := len(g.Players)
|
||||
winner := res.WinnerSeat
|
||||
if winner >= 0 {
|
||||
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.)
|
||||
if winner == g.PrioritySeat {
|
||||
g.PrioritySeat = (winner + 1) % n
|
||||
}
|
||||
}
|
||||
if winner < 0 {
|
||||
g.addLog(LogEntry{Seat: -1, Icon: "⚔️", Kind: LogResult,
|
||||
Text: fmt.Sprintf("Round %d battle ends in a draw.", g.Round)})
|
||||
} else {
|
||||
g.addLog(LogEntry{Seat: winner, Icon: "⚔️", Kind: LogResult,
|
||||
Text: fmt.Sprintf("%s wins the round %d battle (+%d🏆).", g.Players[winner].Name, g.Round, res.Trophies)})
|
||||
// resolveBattles is the orchestrator: it runs each (deterministic) simulation
|
||||
// and publishes the completed results.
|
||||
func (g *Game) resolveBattles() {
|
||||
g.Battles = nil
|
||||
for _, m := range g.Pairings() {
|
||||
first, second := g.firstPlayer(m)
|
||||
res := g.runBattle(first, second)
|
||||
g.Battles = append(g.Battles, res)
|
||||
g.finalizeBattle(res)
|
||||
}
|
||||
// Per-round bookkeeping that isn't tied to one battle: the temporary
|
||||
// resources every player banked for the fight are spent now, win or lose.
|
||||
for _, p := range g.Players {
|
||||
p.PendingApplesInPlay = 0
|
||||
p.PendingTrumpets = 0
|
||||
}
|
||||
}
|
||||
|
||||
// firstPlayer decides which half of a pairing acts first — the side that wins
|
||||
// simultaneity races during the battle. Two players settle it with the
|
||||
// priority token they pass between them; a bigger table flips for it, as the
|
||||
// rulebook's "determine the First Player for each battle by flipping a gold
|
||||
// token" asks.
|
||||
func (g *Game) firstPlayer(m Matchup) (first, second int) {
|
||||
if len(g.Players) == 2 {
|
||||
if m[1] == g.PrioritySeat {
|
||||
return m[1], m[0]
|
||||
}
|
||||
return m[0], m[1]
|
||||
}
|
||||
if randInt(2) == 1 {
|
||||
return m[1], m[0]
|
||||
}
|
||||
return m[0], m[1]
|
||||
}
|
||||
|
||||
// finalizeBattle applies the persistent effects of one completed battle:
|
||||
// trophies, the round-win record, the priority token hand-off, and the result
|
||||
// log line. Kept separate from runBattle, which mutates no persistent player
|
||||
// state.
|
||||
func (g *Game) finalizeBattle(res *BattleResult) {
|
||||
winner := res.WinnerSeat
|
||||
if winner >= 0 {
|
||||
g.Players[winner].Trophies += res.Trophies
|
||||
g.Players[winner].RoundWins = append(g.Players[winner].RoundWins, res.Round)
|
||||
// Priority token (two-player rule): the winner hands it to the other
|
||||
// player; a loser who held it keeps it; a draw leaves it put. At bigger
|
||||
// tables the token instead walks the table each round (startShopRound).
|
||||
if len(g.Players) == 2 && winner == g.PrioritySeat {
|
||||
g.PrioritySeat = (winner + 1) % len(g.Players)
|
||||
}
|
||||
}
|
||||
// The result line names the table it came from, since several resolve at once.
|
||||
loser := res.SeatOf(0)
|
||||
if loser == winner {
|
||||
loser = res.SeatOf(1)
|
||||
}
|
||||
if winner < 0 {
|
||||
g.addLog(LogEntry{Seat: -1, Icon: "⚔️", Kind: LogResult,
|
||||
Text: fmt.Sprintf("%s vs %s ends in a draw.", g.seatName(res.SeatOf(0)), g.seatName(res.SeatOf(1)))})
|
||||
} else {
|
||||
g.addLog(LogEntry{Seat: winner, Icon: "⚔️", Kind: LogResult,
|
||||
Text: fmt.Sprintf("%s beats %s (+%d🏆).", g.seatName(winner), g.seatName(loser), res.Trophies)})
|
||||
}
|
||||
for _, seat := range res.Seats {
|
||||
p := g.Players[seat]
|
||||
side := res.Side(seat)
|
||||
// Unicorn pack: persist the Mana pool as it stood at battle's end, and
|
||||
// bank any apples destined for next round's hand (Skeleton Dog).
|
||||
if res.ManaAfter != nil && p.Seat < len(res.ManaAfter) {
|
||||
p.Mana = res.ManaAfter[p.Seat]
|
||||
if side < len(res.ManaAfter) {
|
||||
p.Mana = res.ManaAfter[side]
|
||||
}
|
||||
if res.NextRoundApples != nil && p.Seat < len(res.NextRoundApples) {
|
||||
p.NextRoundApples += res.NextRoundApples[p.Seat]
|
||||
if side < len(res.NextRoundApples) {
|
||||
p.NextRoundApples += res.NextRoundApples[side]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runBattle plays the simulation to completion, returning the result. It
|
||||
// mutates no persistent player state — that is finalizeBattle's job.
|
||||
func (g *Game) runBattle() *BattleResult {
|
||||
n := len(g.Players)
|
||||
res := &BattleResult{Round: g.Round, WinnerSeat: -1, StackSizes: make([]int, n), Lineups: make([][]Card, n)}
|
||||
// runBattle plays one pairing's simulation to completion, returning the
|
||||
// result. It mutates no persistent player state — that is finalizeBattle's job.
|
||||
//
|
||||
// first and second are the seats fighting, first having priority. Everything
|
||||
// below works in *side* indices — 0 is first, 1 is second — so the resolver
|
||||
// only ever deals with two combatants no matter how big the table is; res.Seats
|
||||
// maps back out. Read `seat` in this function as "side" throughout.
|
||||
func (g *Game) runBattle(first, second int) *BattleResult {
|
||||
const n = 2 // sides in a battle, not players at the table
|
||||
seats := []int{first, second}
|
||||
res := &BattleResult{Round: g.Round, WinnerSeat: -1, Seats: seats,
|
||||
StackSizes: make([]int, n), Lineups: make([][]Card, n)}
|
||||
sides := make([]*battleSide, n)
|
||||
emit := func(ev BattleEvent) { res.Events = append(res.Events, ev) }
|
||||
// pname is the owning player's display name for a seat, for log text.
|
||||
pname := func(seat int) string { return g.Players[seat].Name }
|
||||
// pname is the owning player's display name for a side, for log text.
|
||||
pname := func(side int) string { return g.Players[seats[side]].Name }
|
||||
|
||||
for _, p := range g.Players {
|
||||
for side, seat := range seats {
|
||||
p := g.Players[seat]
|
||||
s := &battleSide{stack: append([]Card(nil), p.Deck...), faintedHats: map[Suit]bool{}}
|
||||
// Unicorn pack: the persistent Mana pool comes into battle (read-only
|
||||
// here; written back by finalizeBattle so re-runs stay deterministic).
|
||||
s.mana = p.Mana
|
||||
sides[p.Seat] = s
|
||||
res.StackSizes[p.Seat] = len(p.Deck)
|
||||
res.Lineups[p.Seat] = append([]Card(nil), p.Deck...)
|
||||
sides[side] = s
|
||||
res.StackSizes[side] = len(p.Deck)
|
||||
res.Lineups[side] = append([]Card(nil), p.Deck...)
|
||||
}
|
||||
// enemyOf returns the opposing side (two-player; generalizes later).
|
||||
// enemyOf returns the opposing side.
|
||||
enemyOf := func(seat int) *battleSide { return sides[(seat+1)%n] }
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
// seatOrder resolves the first player before the second. Reveals, queued
|
||||
// play effects, and cross-side triggers all follow it, so when two pets
|
||||
// would act simultaneously (e.g. both throwing rocks) the first player acts
|
||||
// first — its rocks can faint the enemy pet before that pet's own queued
|
||||
// rocks resolve. Side 0 is the first player by construction.
|
||||
seatOrder := []int{0, 1}
|
||||
// startApple seeds one in-play apple onto a seat's first pet.
|
||||
startApple := func(seat int) {
|
||||
apple := g.newApple()
|
||||
@@ -425,27 +490,28 @@ func (g *Game) runBattle() *BattleResult {
|
||||
}
|
||||
// Battle-prep effects that start apples in play (Monkey): they attach
|
||||
// to the owner's first pet.
|
||||
for _, p := range g.Players {
|
||||
for side, seat := range seats {
|
||||
p := g.Players[seat]
|
||||
for _, c := range p.Deck {
|
||||
for _, e := range c.Effects {
|
||||
if e.Trigger == TriggerBattlePrep && e.Action == ActionApplesInPlay {
|
||||
for range e.count() {
|
||||
startApple(p.Seat)
|
||||
startApple(side)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Golden pack: apples-in-play banked by a sold Hercules Beetle this
|
||||
// round (read-only here; finalizeBattle clears it once the battle ends,
|
||||
// so re-runs bank the same amount).
|
||||
// round (read-only here; resolveBattles clears it once the round's
|
||||
// battles end, so re-runs bank the same amount).
|
||||
for range p.PendingApplesInPlay {
|
||||
startApple(p.Seat)
|
||||
startApple(side)
|
||||
}
|
||||
// Bird of Paradise: start the battle with Trumpets in the pool.
|
||||
if p.PendingTrumpets > 0 {
|
||||
sides[p.Seat].trumpets += p.PendingTrumpets
|
||||
emit(BattleEvent{Type: "trumpet", Seat: p.Seat, Count: p.PendingTrumpets,
|
||||
Text: fmt.Sprintf("%s starts with %d Trumpet%s.", pname(p.Seat), p.PendingTrumpets, plural(p.PendingTrumpets))})
|
||||
sides[side].trumpets += p.PendingTrumpets
|
||||
emit(BattleEvent{Type: "trumpet", Seat: side, Count: p.PendingTrumpets,
|
||||
Text: fmt.Sprintf("%s starts with %d Trumpet%s.", pname(side), p.PendingTrumpets, plural(p.PendingTrumpets))})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -731,160 +797,160 @@ func (g *Game) runBattle() *BattleResult {
|
||||
}
|
||||
}
|
||||
{
|
||||
cause := fmt.Sprintf("%s's faint effect", u.Card.Name)
|
||||
for _, e := range u.effects() {
|
||||
if e.Trigger != TriggerFaint || !allowed(e, u) {
|
||||
continue
|
||||
}
|
||||
if !spend(seat, e, u.Card.Name) {
|
||||
continue
|
||||
}
|
||||
switch e.Action {
|
||||
case ActionSummonTop:
|
||||
target := seat
|
||||
if e.Target == "enemy" {
|
||||
target = (seat + 1) % n
|
||||
cause := fmt.Sprintf("%s's faint effect", u.Card.Name)
|
||||
for _, e := range u.effects() {
|
||||
if e.Trigger != TriggerFaint || !allowed(e, u) {
|
||||
continue
|
||||
}
|
||||
for range effectCount(e, s, u, enemyOf(seat)) {
|
||||
summon(target, mintFor(e.Card), cause)
|
||||
if !spend(seat, e, u.Card.Name) {
|
||||
continue
|
||||
}
|
||||
case ActionSummonBottom:
|
||||
for range effectCount(e, s, u, enemyOf(seat)) {
|
||||
if e.Target == "all" {
|
||||
for other := range sides {
|
||||
summonBottom(other, mintFor(e.Card), cause)
|
||||
switch e.Action {
|
||||
case ActionSummonTop:
|
||||
target := seat
|
||||
if e.Target == "enemy" {
|
||||
target = (seat + 1) % n
|
||||
}
|
||||
for range effectCount(e, s, u, enemyOf(seat)) {
|
||||
summon(target, mintFor(e.Card), cause)
|
||||
}
|
||||
case ActionSummonBottom:
|
||||
for range effectCount(e, s, u, enemyOf(seat)) {
|
||||
if e.Target == "all" {
|
||||
for other := range sides {
|
||||
summonBottom(other, mintFor(e.Card), cause)
|
||||
}
|
||||
} else {
|
||||
summonBottom(seat, mintFor(e.Card), cause)
|
||||
}
|
||||
} else {
|
||||
summonBottom(seat, mintFor(e.Card), cause)
|
||||
}
|
||||
}
|
||||
case ActionGainTrumpet:
|
||||
gainTrumpets(seat, effectCount(e, s, u, enemyOf(seat)), cause)
|
||||
case ActionDrainTrumpet:
|
||||
es := enemyOf(seat)
|
||||
lost := min(e.count(), es.trumpets)
|
||||
if lost > 0 {
|
||||
es.trumpets -= lost
|
||||
emit(BattleEvent{Type: "trumpet", Seat: (seat + 1) % n, Count: -lost,
|
||||
Text: fmt.Sprintf("%s drains %d Trumpet%s from the enemy.", cause, lost, plural(lost))})
|
||||
}
|
||||
case ActionPreventNextHit:
|
||||
s.hitPrevent = append(s.hitPrevent, e.count())
|
||||
s.preventCards = append(s.preventCards, u.Card)
|
||||
setAside()
|
||||
case ActionRecycleApples:
|
||||
recycled := 0
|
||||
for _, f := range u.Foods {
|
||||
if f.Food == FoodApple && recycled < e.count() {
|
||||
summon(seat, f, fmt.Sprintf("%s's faint effect", u.Card.Name))
|
||||
recycled++
|
||||
case ActionGainTrumpet:
|
||||
gainTrumpets(seat, effectCount(e, s, u, enemyOf(seat)), cause)
|
||||
case ActionDrainTrumpet:
|
||||
es := enemyOf(seat)
|
||||
lost := min(e.count(), es.trumpets)
|
||||
if lost > 0 {
|
||||
es.trumpets -= lost
|
||||
emit(BattleEvent{Type: "trumpet", Seat: (seat + 1) % n, Count: -lost,
|
||||
Text: fmt.Sprintf("%s drains %d Trumpet%s from the enemy.", cause, lost, plural(lost))})
|
||||
}
|
||||
}
|
||||
case ActionRecyclePerkApples:
|
||||
// Macaque: recycle up to Count apples, then the active perk on
|
||||
// top (so the perk reveals first and re-attaches to the next pet).
|
||||
recycled := 0
|
||||
for _, f := range u.Foods {
|
||||
if f.Food == FoodApple && recycled < e.count() {
|
||||
summon(seat, f, cause)
|
||||
recycled++
|
||||
case ActionPreventNextHit:
|
||||
s.hitPrevent = append(s.hitPrevent, e.count())
|
||||
s.preventCards = append(s.preventCards, u.Card)
|
||||
setAside()
|
||||
case ActionRecycleApples:
|
||||
recycled := 0
|
||||
for _, f := range u.Foods {
|
||||
if f.Food == FoodApple && recycled < e.count() {
|
||||
summon(seat, f, fmt.Sprintf("%s's faint effect", u.Card.Name))
|
||||
recycled++
|
||||
}
|
||||
}
|
||||
}
|
||||
if perk := u.activePerk(); perk != nil {
|
||||
summon(seat, *perk, cause)
|
||||
}
|
||||
case ActionBeeRocks:
|
||||
s.beePlayRocks = append(s.beePlayRocks, setAsideRocks{dice: e.count(), src: u.Card})
|
||||
setAside()
|
||||
case ActionFeedOnPlay:
|
||||
s.feedOnPlay = append(s.feedOnPlay, feedAside{apples: e.count(), src: u.Card})
|
||||
setAside()
|
||||
case ActionGuardRetriever:
|
||||
s.retrieverGuards = append(s.retrieverGuards, e.count())
|
||||
setAside()
|
||||
case ActionDelayedRocks:
|
||||
s.oneShotRocks = append(s.oneShotRocks,
|
||||
setAsideRocks{dice: e.count(), everyone: e.Target == "all", src: u.Card})
|
||||
setAside()
|
||||
case ActionRecurringRocks:
|
||||
s.recurringRocks = append(s.recurringRocks,
|
||||
setAsideRocks{dice: e.count(), src: u.Card})
|
||||
setAside()
|
||||
case ActionEnemyLastPetRocks:
|
||||
s.lastPetRocks = append(s.lastPetRocks, lastPetVolley{dice: e.count(), src: u.Card})
|
||||
setAside()
|
||||
case ActionShieldNext:
|
||||
s.shields += e.count()
|
||||
s.shieldCards = append(s.shieldCards, u.Card)
|
||||
setAside()
|
||||
case ActionBeeAura:
|
||||
s.beeBonus += e.count()
|
||||
setAside()
|
||||
case ActionPetAura:
|
||||
s.petBonus += e.count()
|
||||
setAside()
|
||||
case ActionGainMana:
|
||||
gainMana(seat, effectCount(e, s, u, enemyOf(seat)), cause)
|
||||
case ActionAddAilment:
|
||||
addAilment(seat, e.Ailment, effectCount(e, s, u, enemyOf(seat)), e.Target == "enemyDeck", cause)
|
||||
case ActionNextRoundApple:
|
||||
// Banked for next round's hand; surfaced then in the shop log
|
||||
// rather than as a battle-board change now.
|
||||
s.nextRoundApples += e.count()
|
||||
case ActionNegateEnemyFaint:
|
||||
s.negators = append(s.negators, u.Card)
|
||||
setAside()
|
||||
case ActionReviveSelf:
|
||||
// Slime: put a plain copy back on top of the deck — no faint
|
||||
// ability, so it can't loop. "Once per round" falls out of that.
|
||||
revived := u.Card
|
||||
revived.ID = g.newCardID()
|
||||
revived.Effects = nil
|
||||
revived.EffectText = ""
|
||||
summon(seat, revived, cause)
|
||||
case ActionAilmentToApples:
|
||||
s.unicornGuards = append(s.unicornGuards, u.Card)
|
||||
setAside()
|
||||
case ActionSmallPetAura:
|
||||
s.smallPetBonus += e.count()
|
||||
setAside()
|
||||
case ActionAilmentBoost:
|
||||
s.ailmentBoost += e.count()
|
||||
setAside()
|
||||
case ActionManaFeedOnPlay:
|
||||
s.manaFeed = append(s.manaFeed, feedAside{apples: e.count(), src: u.Card})
|
||||
setAside()
|
||||
case ActionReviveNextFaint:
|
||||
s.fairyGuards = append(s.fairyGuards, u.Card)
|
||||
setAside()
|
||||
case ActionSummonFromDiscard:
|
||||
// Chimera: add Count random cards from the FromTier discard pile as
|
||||
// temporary copies on top of the deck.
|
||||
pile := g.Discards[e.FromTier]
|
||||
for range effectCount(e, s, u, enemyOf(seat)) {
|
||||
if len(pile) == 0 {
|
||||
break
|
||||
case ActionRecyclePerkApples:
|
||||
// Macaque: recycle up to Count apples, then the active perk on
|
||||
// top (so the perk reveals first and re-attaches to the next pet).
|
||||
recycled := 0
|
||||
for _, f := range u.Foods {
|
||||
if f.Food == FoodApple && recycled < e.count() {
|
||||
summon(seat, f, cause)
|
||||
recycled++
|
||||
}
|
||||
}
|
||||
pick := pile[g.battleDraw(len(pile))]
|
||||
copyC := pick
|
||||
copyC.ID = g.newCardID()
|
||||
copyC.Temporary = true
|
||||
summon(seat, copyC, cause)
|
||||
}
|
||||
case ActionSummonFromTierDeck:
|
||||
// Pixiu: a temporary copy of the top of the FromTier shop deck.
|
||||
if e.FromTier >= 1 && e.FromTier <= len(g.ShopDecks) {
|
||||
deck := g.ShopDecks[e.FromTier-1]
|
||||
if len(deck) > 0 {
|
||||
copyC := deck[0]
|
||||
if perk := u.activePerk(); perk != nil {
|
||||
summon(seat, *perk, cause)
|
||||
}
|
||||
case ActionBeeRocks:
|
||||
s.beePlayRocks = append(s.beePlayRocks, setAsideRocks{dice: e.count(), src: u.Card})
|
||||
setAside()
|
||||
case ActionFeedOnPlay:
|
||||
s.feedOnPlay = append(s.feedOnPlay, feedAside{apples: e.count(), src: u.Card})
|
||||
setAside()
|
||||
case ActionGuardRetriever:
|
||||
s.retrieverGuards = append(s.retrieverGuards, e.count())
|
||||
setAside()
|
||||
case ActionDelayedRocks:
|
||||
s.oneShotRocks = append(s.oneShotRocks,
|
||||
setAsideRocks{dice: e.count(), everyone: e.Target == "all", src: u.Card})
|
||||
setAside()
|
||||
case ActionRecurringRocks:
|
||||
s.recurringRocks = append(s.recurringRocks,
|
||||
setAsideRocks{dice: e.count(), src: u.Card})
|
||||
setAside()
|
||||
case ActionEnemyLastPetRocks:
|
||||
s.lastPetRocks = append(s.lastPetRocks, lastPetVolley{dice: e.count(), src: u.Card})
|
||||
setAside()
|
||||
case ActionShieldNext:
|
||||
s.shields += e.count()
|
||||
s.shieldCards = append(s.shieldCards, u.Card)
|
||||
setAside()
|
||||
case ActionBeeAura:
|
||||
s.beeBonus += e.count()
|
||||
setAside()
|
||||
case ActionPetAura:
|
||||
s.petBonus += e.count()
|
||||
setAside()
|
||||
case ActionGainMana:
|
||||
gainMana(seat, effectCount(e, s, u, enemyOf(seat)), cause)
|
||||
case ActionAddAilment:
|
||||
addAilment(seat, e.Ailment, effectCount(e, s, u, enemyOf(seat)), e.Target == "enemyDeck", cause)
|
||||
case ActionNextRoundApple:
|
||||
// Banked for next round's hand; surfaced then in the shop log
|
||||
// rather than as a battle-board change now.
|
||||
s.nextRoundApples += e.count()
|
||||
case ActionNegateEnemyFaint:
|
||||
s.negators = append(s.negators, u.Card)
|
||||
setAside()
|
||||
case ActionReviveSelf:
|
||||
// Slime: put a plain copy back on top of the deck — no faint
|
||||
// ability, so it can't loop. "Once per round" falls out of that.
|
||||
revived := u.Card
|
||||
revived.ID = g.newCardID()
|
||||
revived.Effects = nil
|
||||
revived.EffectText = ""
|
||||
summon(seat, revived, cause)
|
||||
case ActionAilmentToApples:
|
||||
s.unicornGuards = append(s.unicornGuards, u.Card)
|
||||
setAside()
|
||||
case ActionSmallPetAura:
|
||||
s.smallPetBonus += e.count()
|
||||
setAside()
|
||||
case ActionAilmentBoost:
|
||||
s.ailmentBoost += e.count()
|
||||
setAside()
|
||||
case ActionManaFeedOnPlay:
|
||||
s.manaFeed = append(s.manaFeed, feedAside{apples: e.count(), src: u.Card})
|
||||
setAside()
|
||||
case ActionReviveNextFaint:
|
||||
s.fairyGuards = append(s.fairyGuards, u.Card)
|
||||
setAside()
|
||||
case ActionSummonFromDiscard:
|
||||
// Chimera: add Count random cards from the FromTier discard pile as
|
||||
// temporary copies on top of the deck.
|
||||
pile := g.Discards[e.FromTier]
|
||||
for range effectCount(e, s, u, enemyOf(seat)) {
|
||||
if len(pile) == 0 {
|
||||
break
|
||||
}
|
||||
pick := pile[g.battleDraw(len(pile))]
|
||||
copyC := pick
|
||||
copyC.ID = g.newCardID()
|
||||
copyC.Temporary = true
|
||||
summon(seat, copyC, cause)
|
||||
}
|
||||
case ActionSummonFromTierDeck:
|
||||
// Pixiu: a temporary copy of the top of the FromTier shop deck.
|
||||
if e.FromTier >= 1 && e.FromTier <= len(g.ShopDecks) {
|
||||
deck := g.ShopDecks[e.FromTier-1]
|
||||
if len(deck) > 0 {
|
||||
copyC := deck[0]
|
||||
copyC.ID = g.newCardID()
|
||||
copyC.Temporary = true
|
||||
summon(seat, copyC, cause)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
enemyReactions:
|
||||
// Unicorn pack: a pre-existing Fairy recycles the fallen pet to the deck
|
||||
// bottom (a fresh copy, so it re-enters later and can faint again).
|
||||
@@ -1686,8 +1752,7 @@ func (g *Game) runBattle() *BattleResult {
|
||||
continue // refill before any clash
|
||||
}
|
||||
|
||||
// Clash. Two-player for now; >2-player battle pairings come later
|
||||
// (the surrounding state is already per-seat).
|
||||
// Clash: the two sides' pets trade blows. A is the first player's side.
|
||||
ua, ub := sides[0].unit, sides[1].unit
|
||||
// Unicorn pack: Spooked lowers a pet's clash attack (Exposed is applied
|
||||
// to the defender inside hitUnit); a Manticore boosts enemy ailments.
|
||||
@@ -1768,23 +1833,26 @@ func (g *Game) runBattle() *BattleResult {
|
||||
}
|
||||
}
|
||||
|
||||
// A single side that can still field a pet wins; anything else (everyone
|
||||
// out, or a stalemate with pets on both sides) is a draw. We test canField,
|
||||
// A single side that can still field a pet wins; anything else (both out,
|
||||
// or a stalemate with pets on both sides) is a draw. We test canField,
|
||||
// not unit, because the loop can break the instant one side runs out while
|
||||
// the other's current pet has just fainted — that side still has pets left
|
||||
// in its stack (it simply wasn't refilled) and is the rightful winner.
|
||||
winner := -1
|
||||
for seat, s := range sides {
|
||||
for side, s := range sides {
|
||||
if s.canField() {
|
||||
if winner >= 0 {
|
||||
winner = -1 // stalemate / >2-player safety
|
||||
winner = -1 // both still standing: a stalemate draw
|
||||
break
|
||||
}
|
||||
winner = seat
|
||||
winner = side
|
||||
}
|
||||
}
|
||||
res.WinnerSeat = winner
|
||||
// The winner leaves this function as a seat at the table, the one piece of
|
||||
// the result that means anything outside the battle.
|
||||
if winner >= 0 {
|
||||
res.WinnerSeat = seats[winner]
|
||||
// The last round is worth double.
|
||||
res.Trophies = 1
|
||||
if g.Round == MaxRounds {
|
||||
res.Trophies = 2
|
||||
|
||||
@@ -96,7 +96,7 @@ func forceBattle(t *testing.T, g *Game, d1, d2 []Card) *BattleResult {
|
||||
if g.Phase != PhaseBattle {
|
||||
t.Fatalf("expected battle phase, got %s", g.Phase)
|
||||
}
|
||||
return g.Battle
|
||||
return g.Battles[0]
|
||||
}
|
||||
|
||||
func eventsOfType(res *BattleResult, typ string) []BattleEvent {
|
||||
@@ -712,8 +712,8 @@ func TestPriorityTokenTransfer(t *testing.T) {
|
||||
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.Battles[0].WinnerSeat != 0 {
|
||||
t.Fatalf("seat 0 should win, got %d", g.Battles[0].WinnerSeat)
|
||||
}
|
||||
if g.PrioritySeat != 1 {
|
||||
t.Fatalf("winner should hand the token to the loser, priority=%d", g.PrioritySeat)
|
||||
@@ -723,8 +723,8 @@ func TestPriorityTokenTransfer(t *testing.T) {
|
||||
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.Battles[0].WinnerSeat != 0 {
|
||||
t.Fatalf("seat 0 should win, got %d", g.Battles[0].WinnerSeat)
|
||||
}
|
||||
if g.PrioritySeat != 1 {
|
||||
t.Fatalf("a losing token holder should keep it, priority=%d", g.PrioritySeat)
|
||||
@@ -734,8 +734,8 @@ func TestPriorityTokenTransfer(t *testing.T) {
|
||||
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.Battles[0].WinnerSeat != -1 {
|
||||
t.Fatalf("mutual KO should draw, got %d", g.Battles[0].WinnerSeat)
|
||||
}
|
||||
if g.PrioritySeat != 0 {
|
||||
t.Fatalf("a draw should leave the token put, priority=%d", g.PrioritySeat)
|
||||
|
||||
+94
-73
@@ -1291,98 +1291,119 @@ func packTiers(pack string) (*[MaxRounds][]petTemplate, *[MaxRounds][]foodTempla
|
||||
}
|
||||
}
|
||||
|
||||
// buildShopDecks creates all six tier decks (unshuffled) for the game's pack.
|
||||
// buildShopDecks creates all six tier decks (unshuffled) from the game's
|
||||
// selected packs. Combining packs is the rulebook's answer to seating more
|
||||
// than two players: every pack's tier 1 cards shuffle together into one tier 1
|
||||
// deck, its tier 2 cards into one tier 2 deck, and so on. A game on a single
|
||||
// pack is just the one-element case.
|
||||
func (g *Game) buildShopDecks() {
|
||||
pets, foods := packTiers(g.Pack)
|
||||
g.ShopDecks = make([][]Card, MaxRounds)
|
||||
for tierIdx := range pets {
|
||||
var deck []Card
|
||||
for _, t := range pets[tierIdx] {
|
||||
for _, suit := range t.Suits {
|
||||
deck = append(deck, Card{
|
||||
ID: g.newCardID(),
|
||||
Kind: KindPet,
|
||||
Name: t.Name,
|
||||
Tier: tierIdx + 1,
|
||||
Power: t.Power,
|
||||
Suit: suit,
|
||||
Effects: t.Effects,
|
||||
EffectText: t.EffectText,
|
||||
})
|
||||
for _, pack := range g.packList() {
|
||||
pets, foods := packTiers(pack)
|
||||
for tierIdx := range pets {
|
||||
deck := g.ShopDecks[tierIdx]
|
||||
for _, t := range pets[tierIdx] {
|
||||
for _, suit := range t.Suits {
|
||||
deck = append(deck, Card{
|
||||
ID: g.newCardID(),
|
||||
Kind: KindPet,
|
||||
Name: t.Name,
|
||||
Tier: tierIdx + 1,
|
||||
Power: t.Power,
|
||||
Suit: suit,
|
||||
Effects: t.Effects,
|
||||
EffectText: t.EffectText,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, f := range foods[tierIdx] {
|
||||
for range f.Copies {
|
||||
deck = append(deck, Card{
|
||||
ID: g.newCardID(),
|
||||
Kind: KindFood,
|
||||
Name: f.Name,
|
||||
Tier: tierIdx + 1,
|
||||
Food: f.Food,
|
||||
Perk: f.Perk,
|
||||
Effects: f.Effects,
|
||||
EffectText: f.EffectText,
|
||||
})
|
||||
for _, f := range foods[tierIdx] {
|
||||
for range f.Copies {
|
||||
deck = append(deck, Card{
|
||||
ID: g.newCardID(),
|
||||
Kind: KindFood,
|
||||
Name: f.Name,
|
||||
Tier: tierIdx + 1,
|
||||
Food: f.Food,
|
||||
Perk: f.Perk,
|
||||
Effects: f.Effects,
|
||||
EffectText: f.EffectText,
|
||||
})
|
||||
}
|
||||
}
|
||||
g.ShopDecks[tierIdx] = deck
|
||||
}
|
||||
g.ShopDecks[tierIdx] = deck
|
||||
}
|
||||
}
|
||||
|
||||
// Catalog returns the default pack's representative cards.
|
||||
func Catalog() []Card { return CatalogForPack(DefaultPack) }
|
||||
// packList is the game's pack selection, defaulting to the base pack so a
|
||||
// zero-value Game (scratch simulations, tests) still builds real decks.
|
||||
func (g *Game) packList() []string {
|
||||
if len(g.Packs) == 0 {
|
||||
return []string{DefaultPack}
|
||||
}
|
||||
return g.Packs
|
||||
}
|
||||
|
||||
// CatalogForPack returns one representative card for every pet and food in a
|
||||
// pack, tier by tier, for the debug "buy any card" panel. IDs are name-based
|
||||
// placeholders (not real instances); pets use their first printed suit.
|
||||
func CatalogForPack(pack string) []Card {
|
||||
pets, foods := packTiers(pack)
|
||||
// Catalog returns the default pack's representative cards.
|
||||
func Catalog() []Card { return CatalogForPacks([]string{DefaultPack}) }
|
||||
|
||||
// CatalogForPacks returns one representative card for every pet and food in
|
||||
// the given packs, tier by tier, for the debug "buy any card" panel. IDs are
|
||||
// name-based placeholders (not real instances); pets use their first printed
|
||||
// suit. Cards are grouped by tier across all packs, matching how the shop
|
||||
// decks combine.
|
||||
func CatalogForPacks(packs []string) []Card {
|
||||
var cards []Card
|
||||
for tierIdx := range pets {
|
||||
for _, t := range pets[tierIdx] {
|
||||
suit := SuitRed
|
||||
if len(t.Suits) > 0 {
|
||||
suit = t.Suits[0]
|
||||
for tierIdx := range MaxRounds {
|
||||
for _, pack := range packs {
|
||||
pets, foods := packTiers(pack)
|
||||
for _, t := range pets[tierIdx] {
|
||||
suit := SuitRed
|
||||
if len(t.Suits) > 0 {
|
||||
suit = t.Suits[0]
|
||||
}
|
||||
cards = append(cards, Card{
|
||||
ID: "pet-" + t.Name, Kind: KindPet, Name: t.Name, Tier: tierIdx + 1,
|
||||
Power: t.Power, Suit: suit, Effects: t.Effects, EffectText: t.EffectText,
|
||||
})
|
||||
}
|
||||
for _, f := range foods[tierIdx] {
|
||||
cards = append(cards, Card{
|
||||
ID: "food-" + f.Name, Kind: KindFood, Name: f.Name, Tier: tierIdx + 1,
|
||||
Food: f.Food, Perk: f.Perk, Effects: f.Effects, EffectText: f.EffectText,
|
||||
})
|
||||
}
|
||||
cards = append(cards, Card{
|
||||
ID: "pet-" + t.Name, Kind: KindPet, Name: t.Name, Tier: tierIdx + 1,
|
||||
Power: t.Power, Suit: suit, Effects: t.Effects, EffectText: t.EffectText,
|
||||
})
|
||||
}
|
||||
for _, f := range foods[tierIdx] {
|
||||
cards = append(cards, Card{
|
||||
ID: "food-" + f.Name, Kind: KindFood, Name: f.Name, Tier: tierIdx + 1,
|
||||
Food: f.Food, Perk: f.Perk, Effects: f.Effects, EffectText: f.EffectText,
|
||||
})
|
||||
}
|
||||
}
|
||||
return cards
|
||||
}
|
||||
|
||||
// cardByName mints a fresh instance of the named pet or food from the current
|
||||
// pack's templates (pets take their first printed suit). Returns false if
|
||||
// unknown.
|
||||
// cardByName mints a fresh instance of the named pet or food from the
|
||||
// templates of any pack in play (pets take their first printed suit). Returns
|
||||
// false if unknown.
|
||||
func (g *Game) cardByName(name string) (Card, bool) {
|
||||
pets, foods := packTiers(g.Pack)
|
||||
for tierIdx := range pets {
|
||||
for _, t := range pets[tierIdx] {
|
||||
if t.Name == name {
|
||||
suit := SuitRed
|
||||
if len(t.Suits) > 0 {
|
||||
suit = t.Suits[0]
|
||||
for _, pack := range g.packList() {
|
||||
pets, foods := packTiers(pack)
|
||||
for tierIdx := range pets {
|
||||
for _, t := range pets[tierIdx] {
|
||||
if t.Name == name {
|
||||
suit := SuitRed
|
||||
if len(t.Suits) > 0 {
|
||||
suit = t.Suits[0]
|
||||
}
|
||||
return Card{
|
||||
ID: g.newCardID(), Kind: KindPet, Name: t.Name, Tier: tierIdx + 1,
|
||||
Power: t.Power, Suit: suit, Effects: t.Effects, EffectText: t.EffectText,
|
||||
}, true
|
||||
}
|
||||
return Card{
|
||||
ID: g.newCardID(), Kind: KindPet, Name: t.Name, Tier: tierIdx + 1,
|
||||
Power: t.Power, Suit: suit, Effects: t.Effects, EffectText: t.EffectText,
|
||||
}, true
|
||||
}
|
||||
}
|
||||
for _, f := range foods[tierIdx] {
|
||||
if f.Name == name {
|
||||
return Card{
|
||||
ID: g.newCardID(), Kind: KindFood, Name: f.Name, Tier: tierIdx + 1,
|
||||
Food: f.Food, Perk: f.Perk, Effects: f.Effects, EffectText: f.EffectText,
|
||||
}, true
|
||||
for _, f := range foods[tierIdx] {
|
||||
if f.Name == name {
|
||||
return Card{
|
||||
ID: g.newCardID(), Kind: KindFood, Name: f.Name, Tier: tierIdx + 1,
|
||||
Food: f.Food, Perk: f.Perk, Effects: f.Effects, EffectText: f.EffectText,
|
||||
}, true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+183
-54
@@ -1,6 +1,7 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
@@ -11,8 +12,8 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Tunable rules. The engine supports any player count >= 2; MinPlayers /
|
||||
// MaxPlayers gate when a lobby can start (2 for now, more later).
|
||||
// Tunable rules. A game seats an even number of players (2, 4, or 6) so
|
||||
// everyone has an opponent in every round's pairings; see schedule.go.
|
||||
const (
|
||||
MaxRounds = 6
|
||||
CoinsPerRound = 3
|
||||
@@ -20,7 +21,7 @@ const (
|
||||
MaxPets = 5
|
||||
TradeInCount = 3
|
||||
MinPlayers = 2
|
||||
MaxPlayers = 2
|
||||
MaxPlayers = 6
|
||||
)
|
||||
|
||||
// Phase is the game's top-level state.
|
||||
@@ -37,15 +38,18 @@ const (
|
||||
// Player holds everything about one seat. All fields are exported so a Game
|
||||
// serializes to JSON for persistence.
|
||||
type Player struct {
|
||||
ID string `json:"id"`
|
||||
Token string `json:"token"` // secret; never sent in views
|
||||
Name string `json:"name"`
|
||||
Seat int `json:"seat"`
|
||||
Coins int `json:"coins"`
|
||||
Deck []Card `json:"deck"`
|
||||
Trophies int `json:"trophies"`
|
||||
Ready bool `json:"ready"` // shop passed / arrange submitted / battle acknowledged
|
||||
Connected bool `json:"connected"`
|
||||
ID string `json:"id"`
|
||||
Token string `json:"token"` // secret; never sent in views
|
||||
Name string `json:"name"`
|
||||
Seat int `json:"seat"`
|
||||
Coins int `json:"coins"`
|
||||
Deck []Card `json:"deck"`
|
||||
Trophies int `json:"trophies"`
|
||||
// RoundWins lists the rounds whose battle this player won, in order. The
|
||||
// end-of-game tie-break counts back through it from the final round.
|
||||
RoundWins []int `json:"roundWins,omitempty"`
|
||||
Ready bool `json:"ready"` // shop passed / arrange submitted / battle acknowledged
|
||||
Connected bool `json:"connected"`
|
||||
// TripledThisRound records whether the player used the Triple (trade-in)
|
||||
// action during the current round's shop (Bison's Battle Prep).
|
||||
TripledThisRound bool `json:"tripledThisRound"`
|
||||
@@ -77,7 +81,7 @@ type Player struct {
|
||||
// ShopPeekedRound (Unicorn pack: Bigfoot) is the round the player last used
|
||||
// Bigfoot's reveal (once per round); ShopPeek is the card they saw — a
|
||||
// snapshot of the shop deck's top, shown only in that player's own view.
|
||||
ShopPeekedRound int `json:"shopPeekedRound,omitempty"`
|
||||
ShopPeekedRound int `json:"shopPeekedRound,omitempty"`
|
||||
ShopPeek *Card `json:"shopPeek,omitempty"`
|
||||
// IsBot marks a computer-controlled seat. The engine treats bots exactly
|
||||
// like humans; the server drives their actions. BotLevel is the bot's
|
||||
@@ -139,9 +143,10 @@ type PendingSacrifice struct {
|
||||
type Game struct {
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
// Pack is the selected card pack (see packs.go). Chosen in the lobby by
|
||||
// the host; determines which cards fill the shop decks.
|
||||
Pack string `json:"pack"`
|
||||
// Packs are the selected card packs (see packs.go). Chosen in the lobby by
|
||||
// the host; their tier decks shuffle together to fill the shop. Seating
|
||||
// more than two players requires more than one pack (see PacksNeeded).
|
||||
Packs []string `json:"packs"`
|
||||
Phase Phase `json:"phase"`
|
||||
Round int `json:"round"` // 1-based
|
||||
Players []*Player `json:"players"`
|
||||
@@ -151,11 +156,13 @@ type Game struct {
|
||||
// decks and later left a player's deck (sold, traded, sacrificed), keyed by
|
||||
// tier. Chimera and Abomination draw from it. Temporary cards never enter.
|
||||
Discards map[int][]Card `json:"discards,omitempty"`
|
||||
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.
|
||||
Turn int `json:"turn"` // seat with the current shop turn
|
||||
// PrioritySeat holds the first-shopper token: that seat shops first this
|
||||
// round. How it moves depends on the table size. With two players it is
|
||||
// also the battle's priority token — assigned randomly at game start, then
|
||||
// handed by a winner to the loser (a loser who holds it keeps it, a draw
|
||||
// leaves it put). With more players it starts at seat A and passes one seat
|
||||
// along every round, and each battle flips separately for its first player.
|
||||
PrioritySeat int `json:"prioritySeat"`
|
||||
Pending *PendingTrade `json:"pending,omitempty"`
|
||||
// PendingReveal is an in-progress Cockatoo reveal (Golden pack); it blocks
|
||||
@@ -164,9 +171,15 @@ type Game struct {
|
||||
// PendingSacrifice is an in-progress Water of Youth choice (Unicorn pack);
|
||||
// it blocks other shop actions on that seat until resolved, like Pending.
|
||||
PendingSacrifice *PendingSacrifice `json:"pendingSacrifice,omitempty"`
|
||||
Battle *BattleResult `json:"battle,omitempty"` // most recent battle
|
||||
NextCardID int `json:"nextCardId"`
|
||||
WinnerSeat int `json:"winnerSeat"` // set at gameover; -1 = tie
|
||||
// Battles holds the most recent round's battles — one per pairing (see
|
||||
// schedule.go), so two players produce one and six produce three. They are
|
||||
// all public: everyone can replay every table.
|
||||
Battles []*BattleResult `json:"battles,omitempty"`
|
||||
NextCardID int `json:"nextCardId"`
|
||||
// WinnerSeat is the outright winner at gameover, or -1 when the title is
|
||||
// shared. WinnerSeats always lists every player holding it (see finish).
|
||||
WinnerSeat int `json:"winnerSeat"`
|
||||
WinnerSeats []int `json:"winnerSeats,omitempty"`
|
||||
// Log is the running, human-readable event log shown across every phase.
|
||||
Log []LogEntry `json:"log,omitempty"`
|
||||
LogSeq int `json:"logSeq"` // last assigned entry sequence number
|
||||
@@ -252,7 +265,7 @@ func New() *Game {
|
||||
g := &Game{
|
||||
ID: randomID(16),
|
||||
Code: randomCode(),
|
||||
Pack: DefaultPack,
|
||||
Packs: []string{DefaultPack},
|
||||
Phase: PhaseLobby,
|
||||
WinnerSeat: -1,
|
||||
}
|
||||
@@ -260,6 +273,33 @@ func New() *Game {
|
||||
return g
|
||||
}
|
||||
|
||||
// gameJSON aliases Game so UnmarshalJSON can decode into it without recursing.
|
||||
type gameJSON Game
|
||||
|
||||
// UnmarshalJSON decodes a persisted game, migrating states written before the
|
||||
// game supported more than one pack (a single "pack" string) and before a
|
||||
// round could hold more than one battle (a single "battle" object).
|
||||
func (g *Game) UnmarshalJSON(data []byte) error {
|
||||
aux := struct {
|
||||
*gameJSON
|
||||
LegacyPack string `json:"pack"`
|
||||
LegacyBattle *BattleResult `json:"battle"`
|
||||
}{gameJSON: (*gameJSON)(g)}
|
||||
if err := json.Unmarshal(data, &aux); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(g.Packs) == 0 {
|
||||
g.Packs = []string{cmp.Or(aux.LegacyPack, DefaultPack)}
|
||||
}
|
||||
if len(g.Battles) == 0 && aux.LegacyBattle != nil {
|
||||
if len(aux.LegacyBattle.Seats) == 0 {
|
||||
aux.LegacyBattle.Seats = []int{0, 1} // pre-pairing battles were always A vs B
|
||||
}
|
||||
g.Battles = []*BattleResult{aux.LegacyBattle}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildDecks (re)creates and shuffles the shop decks for the current pack.
|
||||
// Called on creation and whenever the pack changes, so ShopDecks always match
|
||||
// g.Pack and are ready the moment the game starts.
|
||||
@@ -306,20 +346,19 @@ func (g *Game) AddBot(name string, level float64) (*Player, error) {
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// SetPack changes the game's card pack during the lobby and rebuilds the shop
|
||||
// decks to match. Only playable packs may be selected.
|
||||
func (g *Game) SetPack(packID string) error {
|
||||
// SetPacks changes the game's card packs during the lobby and rebuilds the
|
||||
// shop decks to match. Only distinct, playable packs may be selected; how many
|
||||
// are *required* depends on the final player count and is checked at start
|
||||
// (see StartGame), so the host can pick packs and seats in either order.
|
||||
func (g *Game) SetPacks(packIDs []string) error {
|
||||
if g.Phase != PhaseLobby {
|
||||
return fmt.Errorf("%w: game already started", ErrWrongPhase)
|
||||
}
|
||||
pack, ok := packByID(packID)
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: unknown pack", ErrInvalidAction)
|
||||
packs, err := validatePacks(packIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !pack.Playable {
|
||||
return fmt.Errorf("%w: that pack isn't available yet", ErrInvalidAction)
|
||||
}
|
||||
g.Pack = pack.ID
|
||||
g.Packs = packs
|
||||
g.buildDecks()
|
||||
return nil
|
||||
}
|
||||
@@ -344,23 +383,46 @@ func (g *Game) RemovePlayer(targetID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartGame begins the match from the lobby once enough players are seated.
|
||||
// The shop decks are already built for g.Pack (see buildDecks); this just
|
||||
// StartGame begins the match from the lobby once the seats and packs line up.
|
||||
// The shop decks are already built for g.Packs (see buildDecks); this just
|
||||
// validates and makes the transition.
|
||||
func (g *Game) StartGame() error {
|
||||
if g.Phase != PhaseLobby {
|
||||
return fmt.Errorf("%w: game already started", ErrWrongPhase)
|
||||
}
|
||||
if len(g.Players) < MinPlayers {
|
||||
n := len(g.Players)
|
||||
if n < MinPlayers {
|
||||
return fmt.Errorf("%w: need at least %d players to start", ErrInvalidAction, MinPlayers)
|
||||
}
|
||||
if pack, ok := packByID(g.Pack); !ok || !pack.Playable {
|
||||
return fmt.Errorf("%w: that pack isn't available yet", ErrInvalidAction)
|
||||
// Every round pairs players off, so the table has to be even. A lobby with
|
||||
// an odd number of people fills the empty seat with a bot.
|
||||
if !ValidPlayerCount(n) {
|
||||
return fmt.Errorf("%w: %d players can't pair off — play with %s (add or remove a seat)",
|
||||
ErrInvalidAction, n, joinCounts(PlayerCounts))
|
||||
}
|
||||
if _, err := validatePacks(g.Packs); err != nil {
|
||||
return err
|
||||
}
|
||||
if need := PacksNeeded(n); len(g.Packs) < need {
|
||||
return fmt.Errorf("%w: %d players needs at least %d packs shuffled together (%d selected)",
|
||||
ErrInvalidAction, n, need, len(g.Packs))
|
||||
}
|
||||
g.start()
|
||||
return nil
|
||||
}
|
||||
|
||||
// joinCounts renders the legal player counts as "2, 4, or 6".
|
||||
func joinCounts(counts []int) string {
|
||||
parts := make([]string, len(counts))
|
||||
for i, c := range counts {
|
||||
parts[i] = fmt.Sprint(c)
|
||||
}
|
||||
if len(parts) < 2 {
|
||||
return strings.Join(parts, "")
|
||||
}
|
||||
return strings.Join(parts[:len(parts)-1], ", ") + ", or " + parts[len(parts)-1]
|
||||
}
|
||||
|
||||
// PlayerByID returns the player, or nil.
|
||||
func (g *Game) PlayerByID(id string) *Player {
|
||||
for _, p := range g.Players {
|
||||
@@ -373,8 +435,16 @@ 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))
|
||||
// Two players share one token for both jobs, and it starts on a random
|
||||
// seat. With more players the first-shopper token is a separate thing that
|
||||
// simply starts at seat A and walks the table (see startShopRound), while
|
||||
// each battle flips for its own first player.
|
||||
if len(g.Players) == 2 {
|
||||
g.PrioritySeat = randInt(len(g.Players))
|
||||
} else {
|
||||
g.PrioritySeat = 0
|
||||
}
|
||||
g.logf(-1, "🎴", "Game on — %d players, %s.", len(g.Players), PackNames(g.Packs))
|
||||
g.startShopRound()
|
||||
}
|
||||
|
||||
@@ -385,6 +455,12 @@ func (g *Game) startShopRound() {
|
||||
g.Pending = nil
|
||||
g.PendingReveal = nil
|
||||
g.PendingSacrifice = nil
|
||||
// With more than two players the first-shopper token starts on seat A and
|
||||
// passes one seat along at the end of every round. (At two players it
|
||||
// instead follows the battle results — see finalizeBattle.)
|
||||
if len(g.Players) > 2 {
|
||||
g.PrioritySeat = (g.Round - 1) % len(g.Players)
|
||||
}
|
||||
for _, p := range g.Players {
|
||||
p.Coins = CoinsPerRound
|
||||
p.Ready = false
|
||||
@@ -1103,13 +1179,32 @@ func (g *Game) SubmitOrder(playerID string, orderedIDs []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// startBattle enters the battle phase and resolves it.
|
||||
// startBattle enters the battle phase and resolves every pairing in it.
|
||||
func (g *Game) startBattle() {
|
||||
for _, p := range g.Players {
|
||||
p.Ready = false
|
||||
}
|
||||
g.Phase = PhaseBattle
|
||||
g.resolveBattle()
|
||||
g.resolveBattles()
|
||||
}
|
||||
|
||||
// seatName is a seat's display name, for log text.
|
||||
func (g *Game) seatName(seat int) string {
|
||||
if seat < 0 || seat >= len(g.Players) {
|
||||
return "nobody"
|
||||
}
|
||||
return g.Players[seat].Name
|
||||
}
|
||||
|
||||
// BattleFor returns the battle the given seat fought in the current round, or
|
||||
// nil if there isn't one.
|
||||
func (g *Game) BattleFor(seat int) *BattleResult {
|
||||
for _, b := range g.Battles {
|
||||
if b.Has(seat) {
|
||||
return b
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AcknowledgeBattle marks the player done reviewing the battle. When all
|
||||
@@ -1139,21 +1234,55 @@ func (g *Game) AcknowledgeBattle(playerID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// finish ends the game and decides the title. Most trophies wins. Ties are
|
||||
// broken by counting back through the rounds as the rulebook asks: "if only
|
||||
// one of the tied players won round 6, they are the winner. If still tied,
|
||||
// look to round 5, etc." Players still level after every round has been
|
||||
// considered had identical records and share the victory.
|
||||
func (g *Game) finish() {
|
||||
g.Phase = PhaseGameOver
|
||||
best, bestSeat, tie := -1, -1, false
|
||||
best := -1
|
||||
for _, p := range g.Players {
|
||||
switch {
|
||||
case p.Trophies > best:
|
||||
best, bestSeat, tie = p.Trophies, p.Seat, false
|
||||
case p.Trophies == best:
|
||||
tie = true
|
||||
best = max(best, p.Trophies)
|
||||
}
|
||||
var tied []*Player
|
||||
for _, p := range g.Players {
|
||||
if p.Trophies == best {
|
||||
tied = append(tied, p)
|
||||
}
|
||||
}
|
||||
if tie {
|
||||
g.WinnerSeat = -1
|
||||
} else {
|
||||
g.WinnerSeat = bestSeat
|
||||
for round := MaxRounds; round > 0 && len(tied) > 1; round-- {
|
||||
var won []*Player
|
||||
for _, p := range tied {
|
||||
if slices.Contains(p.RoundWins, round) {
|
||||
won = append(won, p)
|
||||
}
|
||||
}
|
||||
// A round only separates them if it split the field: if every remaining
|
||||
// contender won it (or none did), it says nothing and we count back further.
|
||||
if len(won) > 0 && len(won) < len(tied) {
|
||||
tied = won
|
||||
}
|
||||
}
|
||||
g.WinnerSeats = make([]int, len(tied))
|
||||
for i, p := range tied {
|
||||
g.WinnerSeats[i] = p.Seat
|
||||
}
|
||||
slices.Sort(g.WinnerSeats)
|
||||
// WinnerSeat names an outright winner only; a shared title reads as -1.
|
||||
g.WinnerSeat = -1
|
||||
if len(g.WinnerSeats) == 1 {
|
||||
g.WinnerSeat = g.WinnerSeats[0]
|
||||
}
|
||||
switch len(tied) {
|
||||
case 1:
|
||||
g.logf(tied[0].Seat, "👑", "%s wins the game with %d🏆!", tied[0].Name, best)
|
||||
default:
|
||||
names := make([]string, len(tied))
|
||||
for i, p := range tied {
|
||||
names[i] = p.Name
|
||||
}
|
||||
g.logf(-1, "🤝", "%s share the victory with %d🏆 each.", strings.Join(names, " and "), best)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,8 +33,16 @@ func TestLobbyManualStart(t *testing.T) {
|
||||
if _, err := g.AddPlayer("Bob"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := g.AddPlayer("Carol"); err == nil {
|
||||
t.Fatal("third player should be rejected while MaxPlayers=2")
|
||||
// An odd table can't pair off, so a third player has to be matched by a
|
||||
// fourth (or removed) before the host can start.
|
||||
if _, err := g.AddPlayer("Carol"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := g.StartGame(); err == nil {
|
||||
t.Fatal("start should be rejected with an odd number of players")
|
||||
}
|
||||
if err := g.RemovePlayer(g.Players[2].ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The lobby stays open until the host explicitly starts.
|
||||
if g.Phase != PhaseLobby {
|
||||
@@ -503,11 +511,11 @@ func TestFullGameFlow(t *testing.T) {
|
||||
if g.Phase != PhaseBattle {
|
||||
t.Fatalf("expected battle after both arrange, got %s", g.Phase)
|
||||
}
|
||||
if g.Battle.WinnerSeat != p1.Seat {
|
||||
if g.Battles[0].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 {
|
||||
if wantPriority == g.Battles[0].WinnerSeat {
|
||||
wantPriority = (wantPriority + 1) % len(g.Players)
|
||||
}
|
||||
for _, p := range g.Players {
|
||||
|
||||
@@ -10,7 +10,7 @@ import "testing"
|
||||
func goldenGame(t *testing.T) (*Game, *Player, *Player) {
|
||||
t.Helper()
|
||||
g := New()
|
||||
g.Pack = "golden"
|
||||
g.Packs = []string{"golden"}
|
||||
g.buildDecks()
|
||||
p1, err := g.AddPlayer("Alice")
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// pairKey names an unordered pairing, so a schedule can be checked for repeats
|
||||
// regardless of which seat the book printed first.
|
||||
func pairKey(m Matchup) string {
|
||||
a, b := min(m[0], m[1]), max(m[0], m[1])
|
||||
return fmt.Sprintf("%d-%d", a, b)
|
||||
}
|
||||
|
||||
// TestPairingTablesAreWellFormed checks the transcribed rulebook tables against
|
||||
// the properties they must have: every round seats everyone exactly once, and
|
||||
// the opening rounds run a true round-robin (three rounds cover all six pairs
|
||||
// at four players; five rounds cover all fifteen at six) before the schedule
|
||||
// starts replaying earlier rounds to fill out the six.
|
||||
func TestPairingTablesAreWellFormed(t *testing.T) {
|
||||
for _, players := range PlayerCounts {
|
||||
for round := 1; round <= MaxRounds; round++ {
|
||||
ms := Pairings(players, round)
|
||||
if len(ms) != players/2 {
|
||||
t.Fatalf("%dp round %d: got %d battles, want %d", players, round, len(ms), players/2)
|
||||
}
|
||||
seen := map[int]bool{}
|
||||
for _, m := range ms {
|
||||
for _, seat := range m {
|
||||
if seat < 0 || seat >= players {
|
||||
t.Fatalf("%dp round %d: seat %d out of range", players, round, seat)
|
||||
}
|
||||
if seen[seat] {
|
||||
t.Fatalf("%dp round %d: seat %d fights twice", players, round, seat)
|
||||
}
|
||||
seen[seat] = true
|
||||
}
|
||||
if m[0] == m[1] {
|
||||
t.Fatalf("%dp round %d: seat %d paired with itself", players, round, m[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
// The round-robin prefix: enough rounds to pair everyone with everyone,
|
||||
// with no pairing used twice along the way.
|
||||
robin := players - 1
|
||||
if players == 2 {
|
||||
robin = 1
|
||||
}
|
||||
distinct := map[string]bool{}
|
||||
for round := 1; round <= robin; round++ {
|
||||
for _, m := range Pairings(players, round) {
|
||||
key := pairKey(m)
|
||||
if distinct[key] {
|
||||
t.Errorf("%dp: pairing %s repeats inside the first %d rounds", players, key, robin)
|
||||
}
|
||||
distinct[key] = true
|
||||
}
|
||||
}
|
||||
if want := players * (players - 1) / 2; players > 2 && len(distinct) != want {
|
||||
t.Errorf("%dp: first %d rounds cover %d pairings, want all %d", players, robin, len(distinct), want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpponentOfMatchesPairings checks the seat-to-opponent lookup agrees with
|
||||
// the table it reads, in both directions, for every seat and round.
|
||||
func TestOpponentOfMatchesPairings(t *testing.T) {
|
||||
for _, players := range PlayerCounts {
|
||||
for round := 1; round <= MaxRounds; round++ {
|
||||
for seat := range players {
|
||||
opp := OpponentOf(players, round, seat)
|
||||
if opp < 0 {
|
||||
t.Fatalf("%dp round %d: seat %d has no opponent", players, round, seat)
|
||||
}
|
||||
if back := OpponentOf(players, round, opp); back != seat {
|
||||
t.Errorf("%dp round %d: seat %d fights %d, but %d fights %d",
|
||||
players, round, seat, opp, opp, back)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if got := OpponentOf(3, 1, 0); got != -1 {
|
||||
t.Errorf("an unplayable table should have no pairings, got opponent %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCombinedPacksShuffleTogether checks the rulebook's multi-pack rule: the
|
||||
// packs' tier decks merge into one deck per tier, so a combined game's tier 1
|
||||
// holds exactly the tier 1 cards of every pack chosen.
|
||||
func TestCombinedPacksShuffleTogether(t *testing.T) {
|
||||
sizeOf := func(packs ...string) []int {
|
||||
g := &Game{Packs: packs}
|
||||
g.buildShopDecks()
|
||||
sizes := make([]int, MaxRounds)
|
||||
for i, d := range g.ShopDecks {
|
||||
sizes[i] = len(d)
|
||||
}
|
||||
return sizes
|
||||
}
|
||||
turtle, golden := sizeOf("turtle"), sizeOf("golden")
|
||||
both := sizeOf("turtle", "golden")
|
||||
for tier := range MaxRounds {
|
||||
if want := turtle[tier] + golden[tier]; both[tier] != want {
|
||||
t.Errorf("tier %d of the combined decks holds %d cards, want %d+%d=%d",
|
||||
tier+1, both[tier], turtle[tier], golden[tier], want)
|
||||
}
|
||||
}
|
||||
|
||||
// Both packs' cards really are in the same deck, and every card is a
|
||||
// distinct instance — two packs means two of everything, not shared IDs.
|
||||
g := &Game{Packs: []string{"turtle", "golden"}}
|
||||
g.buildShopDecks()
|
||||
names, ids := map[string]bool{}, map[string]bool{}
|
||||
for _, deck := range g.ShopDecks {
|
||||
for _, c := range deck {
|
||||
names[c.Name] = true
|
||||
if ids[c.ID] {
|
||||
t.Fatalf("duplicate card id %q across the combined decks", c.ID)
|
||||
}
|
||||
ids[c.ID] = true
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"Ant", "Cricket", "Groundhog", "Bulldog"} {
|
||||
if !names[want] {
|
||||
t.Errorf("combined Turtle+Golden decks are missing %s", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartGameRequiresEvenTableAndEnoughPacks pins the lobby rules: play
|
||||
// happens in pairs, so the table must be even, and the rulebook asks for one
|
||||
// pack per pair.
|
||||
func TestStartGameRequiresEvenTableAndEnoughPacks(t *testing.T) {
|
||||
newLobby := func(t *testing.T, players int, packs ...string) *Game {
|
||||
t.Helper()
|
||||
g := New()
|
||||
if err := g.SetPacks(packs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := range players {
|
||||
if _, err := g.AddPlayer(fmt.Sprintf("P%d", i)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
if err := newLobby(t, 3, "turtle", "golden").StartGame(); err == nil {
|
||||
t.Error("three players can't pair off and should not start")
|
||||
}
|
||||
if err := newLobby(t, 5, "turtle", "golden", "unicorn").StartGame(); err == nil {
|
||||
t.Error("five players can't pair off and should not start")
|
||||
}
|
||||
if err := newLobby(t, 4, "turtle").StartGame(); err == nil {
|
||||
t.Error("four players on a single pack should not start")
|
||||
}
|
||||
if err := newLobby(t, 6, "turtle", "golden").StartGame(); err == nil {
|
||||
t.Error("six players on two packs should not start")
|
||||
}
|
||||
if err := newLobby(t, 4, "turtle", "golden").StartGame(); err != nil {
|
||||
t.Errorf("four players on two packs should start: %v", err)
|
||||
}
|
||||
if err := newLobby(t, 6, "turtle", "golden", "unicorn").StartGame(); err != nil {
|
||||
t.Errorf("six players on three packs should start: %v", err)
|
||||
}
|
||||
// Two players may still combine packs if they want a deeper shop.
|
||||
if err := newLobby(t, 2, "turtle", "unicorn").StartGame(); err != nil {
|
||||
t.Errorf("two players should be free to combine packs: %v", err)
|
||||
}
|
||||
|
||||
g := New()
|
||||
if err := g.SetPacks([]string{"turtle", "turtle"}); err == nil {
|
||||
t.Error("the same pack twice should be rejected")
|
||||
}
|
||||
if err := g.SetPacks(nil); err == nil {
|
||||
t.Error("an empty pack selection should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMaxPlayersCapacity checks the lobby fills to six seats and no further.
|
||||
func TestMaxPlayersCapacity(t *testing.T) {
|
||||
g := New()
|
||||
for i := range MaxPlayers {
|
||||
if _, err := g.AddPlayer(fmt.Sprintf("P%d", i)); err != nil {
|
||||
t.Fatalf("seating player %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if _, err := g.AddPlayer("one too many"); err == nil {
|
||||
t.Errorf("a %dth player should be turned away", MaxPlayers+1)
|
||||
}
|
||||
}
|
||||
|
||||
// startMulti builds a running game with the given number of seats, enough
|
||||
// packs to cover it, and every player holding one plain pet so battles resolve.
|
||||
func startMulti(t *testing.T, players int) *Game {
|
||||
t.Helper()
|
||||
g := New()
|
||||
packs := []string{"turtle", "golden", "unicorn"}[:PacksNeeded(players)]
|
||||
if err := g.SetPacks(packs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := range players {
|
||||
if _, err := g.AddPlayer(fmt.Sprintf("P%d", i)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := g.StartGame(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// playRound walks a started game through one full round: everyone passes the
|
||||
// shop, submits their deck as-is, and acknowledges the battles.
|
||||
func playRound(t *testing.T, g *Game) {
|
||||
t.Helper()
|
||||
for g.Phase == PhaseShop {
|
||||
p := g.Players[g.Turn]
|
||||
if err := g.Pass(p.ID); err != nil {
|
||||
t.Fatalf("round %d: %s could not pass: %v", g.Round, p.Name, err)
|
||||
}
|
||||
}
|
||||
if g.Phase != PhaseArrange {
|
||||
t.Fatalf("round %d: shop should hand off to arrange, got %s", g.Round, g.Phase)
|
||||
}
|
||||
for _, p := range g.Players {
|
||||
ids := make([]string, len(p.Deck))
|
||||
for i, c := range p.Deck {
|
||||
ids[i] = c.ID
|
||||
}
|
||||
if err := g.SubmitOrder(p.ID, ids); err != nil {
|
||||
t.Fatalf("round %d: %s could not submit: %v", g.Round, p.Name, err)
|
||||
}
|
||||
}
|
||||
if g.Phase != PhaseBattle {
|
||||
t.Fatalf("round %d: arrange should hand off to battle, got %s", g.Round, g.Phase)
|
||||
}
|
||||
for _, p := range g.Players {
|
||||
if err := g.AcknowledgeBattle(p.ID); err != nil {
|
||||
t.Fatalf("round %d: %s could not acknowledge: %v", g.Round, p.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMultiplayerRoundFightsEveryPairing plays 4- and 6-player games end to end
|
||||
// and checks each round resolves exactly the scheduled battles, that every
|
||||
// player is in exactly one of them, and that the trophies handed out match the
|
||||
// results recorded.
|
||||
func TestMultiplayerRoundFightsEveryPairing(t *testing.T) {
|
||||
for _, players := range []int{4, 6} {
|
||||
t.Run(fmt.Sprintf("%dp", players), func(t *testing.T) {
|
||||
g := startMulti(t, players)
|
||||
awarded := make([]int, players)
|
||||
for round := 1; round <= MaxRounds; round++ {
|
||||
if g.Round != round {
|
||||
t.Fatalf("expected round %d, got %d", round, g.Round)
|
||||
}
|
||||
want := Pairings(players, round)
|
||||
playRound(t, g)
|
||||
|
||||
if len(g.Battles) != len(want) {
|
||||
t.Fatalf("round %d resolved %d battles, want %d", round, len(g.Battles), len(want))
|
||||
}
|
||||
fought := map[int]bool{}
|
||||
for i, b := range g.Battles {
|
||||
if len(b.Seats) != 2 {
|
||||
t.Fatalf("round %d battle %d has %d seats", round, i, len(b.Seats))
|
||||
}
|
||||
if got, wantKey := pairKey(Matchup{b.Seats[0], b.Seats[1]}), pairKey(want[i]); got != wantKey {
|
||||
t.Errorf("round %d battle %d paired %s, want %s", round, i, got, wantKey)
|
||||
}
|
||||
for _, seat := range b.Seats {
|
||||
if fought[seat] {
|
||||
t.Errorf("round %d: seat %d fought twice", round, seat)
|
||||
}
|
||||
fought[seat] = true
|
||||
}
|
||||
if b.WinnerSeat >= 0 {
|
||||
if !b.Has(b.WinnerSeat) {
|
||||
t.Errorf("round %d: winner seat %d wasn't in the battle", round, b.WinnerSeat)
|
||||
}
|
||||
awarded[b.WinnerSeat] += b.Trophies
|
||||
}
|
||||
}
|
||||
if len(fought) != players {
|
||||
t.Errorf("round %d: %d of %d players fought", round, len(fought), players)
|
||||
}
|
||||
}
|
||||
if g.Phase != PhaseGameOver {
|
||||
t.Fatalf("game should be over after %d rounds, got %s", MaxRounds, g.Phase)
|
||||
}
|
||||
for _, p := range g.Players {
|
||||
if p.Trophies != awarded[p.Seat] {
|
||||
t.Errorf("%s holds %d trophies, but won %d", p.Name, p.Trophies, awarded[p.Seat])
|
||||
}
|
||||
if len(p.RoundWins) != countWins(g, p.Seat) {
|
||||
t.Errorf("%s recorded %d round wins, want %d", p.Name, len(p.RoundWins), countWins(g, p.Seat))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// countWins is an independent tally of a seat's round wins, read back off the
|
||||
// event log rather than the player record it is checking.
|
||||
func countWins(g *Game, seat int) int {
|
||||
n := 0
|
||||
for _, e := range g.Log {
|
||||
if e.Kind == LogResult && e.Seat == seat {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// TestFirstShopperTokenWalksTheTable checks the multiplayer shop order: the
|
||||
// token starts on seat A and passes one seat along at the end of every round,
|
||||
// and the round's shopping starts with whoever holds it.
|
||||
func TestFirstShopperTokenWalksTheTable(t *testing.T) {
|
||||
g := startMulti(t, 4)
|
||||
for round := 1; round <= MaxRounds; round++ {
|
||||
want := (round - 1) % len(g.Players)
|
||||
if g.PrioritySeat != want {
|
||||
t.Errorf("round %d: first shopper is seat %d, want %d", round, g.PrioritySeat, want)
|
||||
}
|
||||
if g.Turn != want {
|
||||
t.Errorf("round %d: shopping starts at seat %d, want %d", round, g.Turn, want)
|
||||
}
|
||||
playRound(t, g)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTieBreakCountsBackFromTheLastRound pins the rulebook's tie-break: level
|
||||
// on trophies, the title goes to whoever won the latest round that separates
|
||||
// them; identical records share it.
|
||||
func TestTieBreakCountsBackFromTheLastRound(t *testing.T) {
|
||||
// finishWith runs finish() over a table whose trophies and round wins are
|
||||
// set directly, which is the only state the tie-break reads.
|
||||
finishWith := func(records ...[]int) *Game {
|
||||
g := &Game{Phase: PhaseBattle, Round: MaxRounds, WinnerSeat: -1}
|
||||
for i, wins := range records {
|
||||
trophies := 0
|
||||
for _, r := range wins {
|
||||
trophies++
|
||||
if r == MaxRounds {
|
||||
trophies++ // the final round is worth double
|
||||
}
|
||||
}
|
||||
g.Players = append(g.Players, &Player{
|
||||
Name: string(rune('A' + i)), Seat: i, Trophies: trophies, RoundWins: wins,
|
||||
})
|
||||
}
|
||||
g.finish()
|
||||
return g
|
||||
}
|
||||
|
||||
// Different trophy counts need no tie-break at all.
|
||||
if g := finishWith([]int{1, 2}, []int{3}); g.WinnerSeat != 0 {
|
||||
t.Errorf("most trophies should win outright, got seat %d", g.WinnerSeat)
|
||||
}
|
||||
|
||||
// Level on trophies: seat 1 took the final round, so it takes the title.
|
||||
g := finishWith([]int{1, 2, 3}, []int{1, 2, MaxRounds})
|
||||
if g.WinnerSeat != 1 {
|
||||
t.Errorf("the round-%d winner should break the tie, got seat %d", MaxRounds, g.WinnerSeat)
|
||||
}
|
||||
|
||||
// Neither won the last round, so the countback keeps going: both won round
|
||||
// 3, which separates nobody, and round 2 decides it.
|
||||
g = finishWith([]int{2, 3}, []int{1, 3})
|
||||
if g.WinnerSeat != 0 {
|
||||
t.Errorf("countback should reach round 2 and pick seat 0, got seat %d", g.WinnerSeat)
|
||||
}
|
||||
|
||||
// Identical records share the victory.
|
||||
g = finishWith([]int{1, 3}, []int{1, 3}, []int{2})
|
||||
if g.WinnerSeat != -1 {
|
||||
t.Errorf("an unbreakable tie should have no outright winner, got seat %d", g.WinnerSeat)
|
||||
}
|
||||
if want := []int{0, 1}; !slices.Equal(g.WinnerSeats, want) {
|
||||
t.Errorf("shared victory listed %v, want %v", g.WinnerSeats, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestViewShowsEveryTableButKeepsSecrets checks a player's view of a six-player
|
||||
// round: all three battles are public and replayable, their own is singled out,
|
||||
// and nobody else's hand leaks.
|
||||
func TestViewShowsEveryTableButKeepsSecrets(t *testing.T) {
|
||||
g := startMulti(t, 6)
|
||||
playRound(t, g)
|
||||
|
||||
me := g.Players[2]
|
||||
v := g.ViewFor(me.ID)
|
||||
if len(v.Battles) != 3 {
|
||||
t.Fatalf("view shows %d battles, want all 3", len(v.Battles))
|
||||
}
|
||||
if v.Battle == nil || !v.Battle.Has(me.Seat) {
|
||||
t.Fatal("the view should single out the battle the viewer fought")
|
||||
}
|
||||
for _, b := range v.Battles {
|
||||
if len(b.Lineups) != 2 {
|
||||
t.Errorf("a battle result should carry both sides' lineups, got %d", len(b.Lineups))
|
||||
}
|
||||
}
|
||||
for _, pv := range v.Players {
|
||||
if pv.Seat != me.Seat && pv.Deck != nil {
|
||||
t.Errorf("seat %d's hand leaked into seat %d's view", pv.Seat, me.Seat)
|
||||
}
|
||||
}
|
||||
// The pairings are printed in the rulebook, so they're public.
|
||||
if v.YourOpponent != OpponentOf(6, v.Round, me.Seat) {
|
||||
t.Errorf("view names opponent %d, schedule says %d", v.YourOpponent, OpponentOf(6, v.Round, me.Seat))
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Card packs are the selectable sets of pets and food a game is played with.
|
||||
// Turtle, Golden, and Unicorn all ship with full six-tier card data.
|
||||
|
||||
@@ -31,3 +36,73 @@ func packByID(id string) (PackInfo, bool) {
|
||||
}
|
||||
return PackInfo{}, false
|
||||
}
|
||||
|
||||
// PacksNeeded is how many packs a game must combine to seat n players: the
|
||||
// rulebook asks for at least 2 packs at 4 players and 3 at 6, i.e. one per
|
||||
// pair. More packs than the minimum are always allowed — a deeper shop just
|
||||
// means fewer repeated pets.
|
||||
func PacksNeeded(players int) int {
|
||||
if players < MinPlayers {
|
||||
return 1
|
||||
}
|
||||
return players / 2
|
||||
}
|
||||
|
||||
// sortPacks puts a pack selection into catalog order, so the same choice
|
||||
// always reads the same way in views and logs.
|
||||
func sortPacks(ids []string) []string {
|
||||
out := make([]string, 0, len(ids))
|
||||
for _, p := range Packs {
|
||||
for _, id := range ids {
|
||||
if id == p.ID {
|
||||
out = append(out, p.ID)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// validatePacks checks a pack selection: non-empty, known, playable, and free
|
||||
// of duplicates. It returns the selection in catalog order.
|
||||
func validatePacks(ids []string) ([]string, error) {
|
||||
if len(ids) == 0 {
|
||||
return nil, fmt.Errorf("%w: pick at least one pack", ErrInvalidAction)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, id := range ids {
|
||||
pack, ok := packByID(id)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: unknown pack %q", ErrInvalidAction, id)
|
||||
}
|
||||
if !pack.Playable {
|
||||
return nil, fmt.Errorf("%w: the %s isn't available yet", ErrInvalidAction, pack.Name)
|
||||
}
|
||||
if seen[id] {
|
||||
return nil, fmt.Errorf("%w: %s is selected twice", ErrInvalidAction, pack.Name)
|
||||
}
|
||||
seen[id] = true
|
||||
}
|
||||
return sortPacks(ids), nil
|
||||
}
|
||||
|
||||
// PackNames renders a pack selection as a readable list ("Turtle Pack and
|
||||
// Golden Pack") for log lines.
|
||||
func PackNames(ids []string) string {
|
||||
names := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if p, ok := packByID(id); ok {
|
||||
names = append(names, p.Name)
|
||||
}
|
||||
}
|
||||
switch len(names) {
|
||||
case 0:
|
||||
return "no packs"
|
||||
case 1:
|
||||
return names[0]
|
||||
case 2:
|
||||
return names[0] + " and " + names[1]
|
||||
default:
|
||||
return strings.Join(names[:len(names)-1], ", ") + ", and " + names[len(names)-1]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package game
|
||||
|
||||
// Battle pairings for multiplayer games ("4 & 6 Player Mode" in the rulebook).
|
||||
//
|
||||
// Every round, players split into pairs and each pair fights its own battle.
|
||||
// The pairings are a fixed table printed in the book — seats are lettered
|
||||
// A, B, C… in seat order, so seat 0 is A. Both tables are transcribed
|
||||
// literally rather than generated: each is a round-robin that runs out of
|
||||
// fresh pairings before six rounds are up (three rounds exhaust four players,
|
||||
// five rounds exhaust six), and the book's choice of which earlier rounds to
|
||||
// replay for the remainder is a decision, not something a generator would
|
||||
// reproduce.
|
||||
|
||||
// Matchup is one battle: the two seats that fight it. The order is the printed
|
||||
// order and carries no meaning on its own — the first player of each battle is
|
||||
// decided separately (a coin flip; see Game.startBattles).
|
||||
type Matchup [2]int
|
||||
|
||||
// pairingTables maps a player count to its per-round pairings, indexed by
|
||||
// round-1. Two players is the degenerate case: one pairing, every round.
|
||||
var pairingTables = map[int][MaxRounds][]Matchup{
|
||||
2: {
|
||||
{{0, 1}}, // R1 A/B
|
||||
{{0, 1}}, // R2 A/B
|
||||
{{0, 1}}, // R3 A/B
|
||||
{{0, 1}}, // R4 A/B
|
||||
{{0, 1}}, // R5 A/B
|
||||
{{0, 1}}, // R6 A/B
|
||||
},
|
||||
4: {
|
||||
{{0, 1}, {2, 3}}, // R1 A/B C/D
|
||||
{{0, 2}, {1, 3}}, // R2 A/C B/D
|
||||
{{0, 3}, {1, 2}}, // R3 A/D B/C
|
||||
{{0, 1}, {2, 3}}, // R4 A/B C/D
|
||||
{{0, 2}, {1, 3}}, // R5 A/C B/D
|
||||
{{0, 3}, {1, 2}}, // R6 A/D B/C
|
||||
},
|
||||
6: {
|
||||
{{0, 1}, {2, 3}, {4, 5}}, // R1 A/B C/D E/F
|
||||
{{0, 2}, {1, 4}, {3, 5}}, // R2 A/C B/E D/F
|
||||
{{0, 3}, {1, 5}, {2, 4}}, // R3 A/D B/F C/E
|
||||
{{0, 4}, {1, 3}, {2, 5}}, // R4 A/E B/D C/F
|
||||
{{0, 5}, {1, 2}, {3, 4}}, // R5 A/F B/C D/E
|
||||
{{0, 1}, {2, 3}, {4, 5}}, // R6 A/B C/D E/F
|
||||
},
|
||||
}
|
||||
|
||||
// PlayerCounts lists the player counts a game can be played at, in order. The
|
||||
// game needs an even number of players so everyone has an opponent every
|
||||
// round; a lobby with an odd number of humans fills the gap with bots.
|
||||
var PlayerCounts = []int{2, 4, 6}
|
||||
|
||||
// ValidPlayerCount reports whether n players can start a game.
|
||||
func ValidPlayerCount(n int) bool {
|
||||
_, ok := pairingTables[n]
|
||||
return ok
|
||||
}
|
||||
|
||||
// Pairings returns the battles for a round (1-based) at the given player
|
||||
// count, or nil if either is out of range. The returned slice is shared table
|
||||
// data — treat it as read-only.
|
||||
func Pairings(players, round int) []Matchup {
|
||||
table, ok := pairingTables[players]
|
||||
if !ok || round < 1 || round > MaxRounds {
|
||||
return nil
|
||||
}
|
||||
return table[round-1]
|
||||
}
|
||||
|
||||
// OpponentOf returns the seat that `seat` fights in the given round, or -1 if
|
||||
// it has no battle (which the fixed tables never produce for a valid count).
|
||||
func OpponentOf(players, round, seat int) int {
|
||||
for _, m := range Pairings(players, round) {
|
||||
switch seat {
|
||||
case m[0]:
|
||||
return m[1]
|
||||
case m[1]:
|
||||
return m[0]
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// Pairings returns this round's battle pairings for the game's player count.
|
||||
func (g *Game) Pairings() []Matchup {
|
||||
return Pairings(len(g.Players), g.Round)
|
||||
}
|
||||
+19
-17
@@ -1,15 +1,15 @@
|
||||
package game
|
||||
|
||||
// SimulateBattle resolves a hypothetical two-player battle between the given
|
||||
// arranged decks (top of deck first) and returns the result. It runs on a
|
||||
// scratch game, so it never touches real state — callers (notably the AI
|
||||
// player) can roll out as many what-if battles as they like. Dice rolls are
|
||||
// random unless rollDie is non-nil.
|
||||
func SimulateBattle(round, prioritySeat int, deckA, deckB []Card, rollDie func() int) *BattleResult {
|
||||
// SimulateBattle resolves a hypothetical battle between the given arranged
|
||||
// decks (top of deck first) and returns the result. deckA sits at seat 0 and
|
||||
// deckB at seat 1; firstSeat (0 or 1) is the one holding priority. It runs on
|
||||
// a scratch game and awards nothing, so it never touches real state — callers
|
||||
// (notably the AI player) can roll out as many what-if battles as they like.
|
||||
// Dice rolls are random unless rollDie is non-nil.
|
||||
func SimulateBattle(round, firstSeat int, deckA, deckB []Card, rollDie func() int) *BattleResult {
|
||||
g := &Game{
|
||||
Round: round,
|
||||
PrioritySeat: prioritySeat,
|
||||
RollDie: rollDie,
|
||||
Round: round,
|
||||
RollDie: rollDie,
|
||||
// Cards minted during the simulation (apples, bees) get IDs far away
|
||||
// from real ones, purely to avoid confusion when reading results.
|
||||
NextCardID: 1_000_000,
|
||||
@@ -18,22 +18,24 @@ func SimulateBattle(round, prioritySeat int, deckA, deckB []Card, rollDie func()
|
||||
{Name: "B", Seat: 1, Deck: append([]Card(nil), deckB...)},
|
||||
},
|
||||
}
|
||||
g.startBattle()
|
||||
return g.Battle
|
||||
if firstSeat == 1 {
|
||||
return g.runBattle(1, 0)
|
||||
}
|
||||
return g.runBattle(0, 1)
|
||||
}
|
||||
|
||||
// TierContents returns the full printed contents of a tier's shop deck for the
|
||||
// default pack. Cards carry placeholder IDs; they are reference data, not live
|
||||
// instances.
|
||||
func TierContents(tier int) []Card {
|
||||
return TierContentsForPack(DefaultPack, tier)
|
||||
return TierContentsForPacks([]string{DefaultPack}, tier)
|
||||
}
|
||||
|
||||
// TierContentsForPack returns a pack's printed tier contents — public
|
||||
// information from the box. Used by the AI, which decides from a View that
|
||||
// names its pack.
|
||||
func TierContentsForPack(pack string, tier int) []Card {
|
||||
scratch := &Game{Pack: pack}
|
||||
// TierContentsForPacks returns the printed tier contents of a pack selection,
|
||||
// combined the way the shop decks combine them — public information from the
|
||||
// boxes. Used by the AI, which decides from a View that names its packs.
|
||||
func TierContentsForPacks(packs []string, tier int) []Card {
|
||||
scratch := &Game{Packs: packs}
|
||||
scratch.buildShopDecks()
|
||||
if tier < 1 || tier > len(scratch.ShopDecks) {
|
||||
return nil
|
||||
|
||||
@@ -603,7 +603,7 @@ func TestSnakeRecurringRocks(t *testing.T) {
|
||||
func TestWolverineStealsApples(t *testing.T) {
|
||||
g, _, _ := testGame(t)
|
||||
res := forceBattle(t, g,
|
||||
[]Card{g.pet("Chip", 4), g.realPet(t, "Wolverine")}, // wolverine: 5
|
||||
[]Card{g.pet("Chip", 4), g.realPet(t, "Wolverine")}, // wolverine: 5
|
||||
[]Card{g.newApple(), g.newApple(), g.newApple(), g.newApple(), g.pet("Hoard", 2)}, // 2+4=6
|
||||
)
|
||||
// Chip (4) dies to Hoard (6); Hoard carries 4 damage (2 health).
|
||||
|
||||
@@ -9,7 +9,7 @@ import "testing"
|
||||
func unicornGame(t *testing.T) (*Game, *Player, *Player) {
|
||||
t.Helper()
|
||||
g := New()
|
||||
g.Pack = "unicorn"
|
||||
g.Packs = []string{"unicorn"}
|
||||
g.buildDecks()
|
||||
p1, err := g.AddPlayer("Alice")
|
||||
if err != nil {
|
||||
|
||||
+69
-29
@@ -3,16 +3,19 @@ package game
|
||||
// PlayerView is what any player may know about a seat. Deck contents are
|
||||
// only included for the viewer's own seat; opponents see counts.
|
||||
type PlayerView struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Seat int `json:"seat"`
|
||||
Coins int `json:"coins"`
|
||||
Trophies int `json:"trophies"`
|
||||
Ready bool `json:"ready"`
|
||||
Connected bool `json:"connected"`
|
||||
IsBot bool `json:"isBot,omitempty"`
|
||||
DeckSize int `json:"deckSize"`
|
||||
PetCount int `json:"petCount"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Seat int `json:"seat"`
|
||||
Coins int `json:"coins"`
|
||||
Trophies int `json:"trophies"`
|
||||
// RoundWins are the rounds this player won a battle in. Public — every
|
||||
// result is — and what the end-of-game countback tie-break runs on.
|
||||
RoundWins []int `json:"roundWins,omitempty"`
|
||||
Ready bool `json:"ready"`
|
||||
Connected bool `json:"connected"`
|
||||
IsBot bool `json:"isBot,omitempty"`
|
||||
DeckSize int `json:"deckSize"`
|
||||
PetCount int `json:"petCount"`
|
||||
// Avocados is the player's set-aside Avocado token count (Golden pack).
|
||||
// Public: buying an Avocado is a public shop event.
|
||||
Avocados int `json:"avocados,omitempty"`
|
||||
@@ -40,21 +43,31 @@ type View struct {
|
||||
Round int `json:"round"`
|
||||
MaxRounds int `json:"maxRounds"`
|
||||
MaxPets int `json:"maxPets"`
|
||||
// Pack is the selected card pack; Packs is the catalog of choices for the
|
||||
// lobby. HostSeat is the seat that controls the lobby (always 0 for now);
|
||||
// MinPlayers is how many seats must be filled before the host can start.
|
||||
Pack string `json:"pack"`
|
||||
Packs []PackInfo `json:"packs"`
|
||||
HostSeat int `json:"hostSeat"`
|
||||
MinPlayers int `json:"minPlayers"`
|
||||
MaxPlayers int `json:"maxPlayers"`
|
||||
YouSeat int `json:"youSeat"`
|
||||
Turn int `json:"turn"`
|
||||
// PrioritySeat is the seat currently holding the priority token.
|
||||
// Packs are the selected card packs, whose tiers shuffle together;
|
||||
// PackCatalog is the list of choices for the lobby and PacksNeeded is how
|
||||
// many the current table size requires. HostSeat is the seat that controls
|
||||
// the lobby (always 0 for now); PlayerCounts lists the table sizes a game
|
||||
// can start at.
|
||||
Packs []string `json:"packs"`
|
||||
PackCatalog []PackInfo `json:"packCatalog"`
|
||||
PacksNeeded int `json:"packsNeeded"`
|
||||
HostSeat int `json:"hostSeat"`
|
||||
MinPlayers int `json:"minPlayers"`
|
||||
MaxPlayers int `json:"maxPlayers"`
|
||||
PlayerCounts []int `json:"playerCounts"`
|
||||
YouSeat int `json:"youSeat"`
|
||||
Turn int `json:"turn"`
|
||||
// PrioritySeat is the seat holding the first-shopper token: it shops first
|
||||
// this round (and, in a two-player game, also acts first in the battle).
|
||||
PrioritySeat int `json:"prioritySeat"`
|
||||
ShopRow []Card `json:"shopRow"`
|
||||
DeckCounts []int `json:"deckCounts"` // remaining shop cards per tier
|
||||
Players []PlayerView `json:"players"`
|
||||
// Matchups are this round's battle pairings — public, since the schedule is
|
||||
// printed in the rulebook. YourOpponent is the seat you face this round, or
|
||||
// -1 outside a running game.
|
||||
Matchups []Matchup `json:"matchups,omitempty"`
|
||||
YourOpponent int `json:"yourOpponent"`
|
||||
// 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"`
|
||||
@@ -65,8 +78,14 @@ type View struct {
|
||||
// PendingSacrifice (Unicorn pack: Water of Youth) mirrors PendingReveal: the
|
||||
// options (the buyer's own pets) are only sent to the buyer.
|
||||
PendingSacrifice *PendingSacrifice `json:"pendingSacrifice,omitempty"`
|
||||
Battle *BattleResult `json:"battle,omitempty"`
|
||||
WinnerSeat int `json:"winnerSeat"`
|
||||
// Battle is the viewer's own battle this round; Battles holds every table's,
|
||||
// so a player can replay any of them. Both are public once resolved.
|
||||
Battle *BattleResult `json:"battle,omitempty"`
|
||||
Battles []*BattleResult `json:"battles,omitempty"`
|
||||
// WinnerSeat is the outright winner at gameover (-1 when shared);
|
||||
// WinnerSeats lists everyone holding the title.
|
||||
WinnerSeat int `json:"winnerSeat"`
|
||||
WinnerSeats []int `json:"winnerSeats,omitempty"`
|
||||
// Log is the shared, public event log shown across every phase.
|
||||
Log []LogEntry `json:"log,omitempty"`
|
||||
// Debug is set by the server when its DEBUG flag is on, unlocking the
|
||||
@@ -83,16 +102,21 @@ func (g *Game) ViewFor(playerID string) View {
|
||||
Round: g.Round,
|
||||
MaxRounds: MaxRounds,
|
||||
MaxPets: MaxPets,
|
||||
Pack: g.Pack,
|
||||
Packs: Packs,
|
||||
Packs: g.packList(),
|
||||
PackCatalog: Packs,
|
||||
PacksNeeded: PacksNeeded(len(g.Players)),
|
||||
HostSeat: 0,
|
||||
MinPlayers: MinPlayers,
|
||||
MaxPlayers: MaxPlayers,
|
||||
PlayerCounts: PlayerCounts,
|
||||
YouSeat: -1,
|
||||
YourOpponent: -1,
|
||||
Turn: g.Turn,
|
||||
PrioritySeat: g.PrioritySeat,
|
||||
ShopRow: g.ShopRow,
|
||||
WinnerSeat: g.WinnerSeat,
|
||||
WinnerSeats: g.WinnerSeats,
|
||||
Matchups: g.Pairings(),
|
||||
Log: g.Log,
|
||||
}
|
||||
for _, deck := range g.ShopDecks {
|
||||
@@ -105,6 +129,7 @@ func (g *Game) ViewFor(playerID string) View {
|
||||
Seat: p.Seat,
|
||||
Coins: p.Coins,
|
||||
Trophies: p.Trophies,
|
||||
RoundWins: p.RoundWins,
|
||||
Ready: p.Ready,
|
||||
Connected: p.Connected,
|
||||
IsBot: p.IsBot,
|
||||
@@ -115,6 +140,7 @@ func (g *Game) ViewFor(playerID string) View {
|
||||
}
|
||||
if p.ID == playerID {
|
||||
v.YouSeat = p.Seat
|
||||
v.YourOpponent = OpponentOf(len(g.Players), g.Round, p.Seat)
|
||||
pv.Deck = p.Deck
|
||||
pv.FirstBuyFree = p.FirstBuyFree
|
||||
pv.BuysThisRound = p.BuysThisRound
|
||||
@@ -144,9 +170,23 @@ func (g *Game) ViewFor(playerID string) View {
|
||||
}
|
||||
v.PendingSacrifice = &sac
|
||||
}
|
||||
// Battle results (lineups, events) are public once resolved. Keep the
|
||||
// battle around during the following shop phase too, so late joiners /
|
||||
// reconnects can still see the last result.
|
||||
v.Battle = g.Battle
|
||||
// Battle results (lineups, events) are public once resolved — every table's,
|
||||
// not just your own, so players can watch how the rest of the field did.
|
||||
// They stay around during the following shop phase too, so late joiners /
|
||||
// reconnects can still see the last round.
|
||||
v.Battles = g.Battles
|
||||
if v.YouSeat >= 0 {
|
||||
v.Battle = g.BattleFor(v.YouSeat)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// PlayerView returns the view of one seat within a View, or nil.
|
||||
func (v *View) PlayerView(seat int) *PlayerView {
|
||||
for i := range v.Players {
|
||||
if v.Players[i].Seat == seat {
|
||||
return &v.Players[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -96,3 +96,113 @@ func TestE2EBotGame(t *testing.T) {
|
||||
}
|
||||
t.Logf("reached phase %s; bot played its shop turns", v.Phase)
|
||||
}
|
||||
|
||||
// TestE2EFourPlayerBotGame runs a four-seat game (one human, three bots) over
|
||||
// the API and plays a full round. It's the multiplayer counterpart to
|
||||
// TestE2EBotGame: the bot driver has to keep three seats moving through a
|
||||
// shop that takes turns four ways, and the round has to resolve two battles at
|
||||
// once rather than one.
|
||||
func TestE2EFourPlayerBotGame(t *testing.T) {
|
||||
st, err := store.Open(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
srv := New(st, "", false)
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
defer ts.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
resp, err := http.Post(ts.URL+"/api/games", "application/json",
|
||||
bytes.NewBufferString(`{"name":"Human"}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var join joinResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&join); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
wsBase := "ws" + strings.TrimPrefix(ts.URL, "http")
|
||||
ws, _, err := websocket.Dial(ctx,
|
||||
wsBase+"/api/ws?game="+join.GameID+"&player="+join.PlayerID+"&token="+join.Token, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ws.Close(websocket.StatusNormalClosure, "")
|
||||
|
||||
if p := readState(t, ctx, ws).Phase; p != game.PhaseLobby {
|
||||
t.Fatalf("phase = %s, want lobby on create", p)
|
||||
}
|
||||
// Four seats need two packs shuffled together, per the rulebook.
|
||||
send(t, ctx, ws, map[string]any{"type": "setPacks", "packs": []string{"turtle", "golden"}})
|
||||
for _, level := range []string{"easy", "medium", "hard"} {
|
||||
send(t, ctx, ws, map[string]any{"type": "addBot", "difficulty": level})
|
||||
}
|
||||
send(t, ctx, ws, map[string]any{"type": "start"})
|
||||
|
||||
var v *game.View
|
||||
deadline := time.Now().Add(2 * time.Minute)
|
||||
for time.Now().Before(deadline) {
|
||||
rctx, rcancel := context.WithTimeout(ctx, 20*time.Second)
|
||||
v = readState(t, rctx, ws)
|
||||
rcancel()
|
||||
if v.Phase == game.PhaseLobby {
|
||||
continue
|
||||
}
|
||||
if len(v.Players) != 4 {
|
||||
t.Fatalf("expected 4 seats, got %d", len(v.Players))
|
||||
}
|
||||
// Play the human side: pass as soon as it's our turn, then submit our
|
||||
// deck as-is. The round can only progress if the three bots take their
|
||||
// own turns around us.
|
||||
switch v.Phase {
|
||||
case game.PhaseShop:
|
||||
if v.Turn == v.YouSeat && !v.Players[v.YouSeat].Ready && v.Pending == nil &&
|
||||
v.PendingReveal == nil && v.PendingSacrifice == nil {
|
||||
send(t, ctx, ws, map[string]any{"type": "pass"})
|
||||
}
|
||||
case game.PhaseArrange:
|
||||
if !v.Players[v.YouSeat].Ready {
|
||||
ids := []string{}
|
||||
for _, c := range v.Players[v.YouSeat].Deck {
|
||||
ids = append(ids, c.ID)
|
||||
}
|
||||
send(t, ctx, ws, map[string]any{"type": "arrange", "order": ids})
|
||||
}
|
||||
case game.PhaseBattle:
|
||||
goto resolved
|
||||
}
|
||||
}
|
||||
t.Fatalf("never reached the battle phase; stuck in %s", v.Phase)
|
||||
|
||||
resolved:
|
||||
// Every bot name should be distinct, or the table is unreadable.
|
||||
names := map[string]bool{}
|
||||
for _, p := range v.Players {
|
||||
if names[p.Name] {
|
||||
t.Errorf("two seats are both called %q", p.Name)
|
||||
}
|
||||
names[p.Name] = true
|
||||
}
|
||||
// Four players pair into two simultaneous battles, together seating everyone.
|
||||
if len(v.Battles) != 2 {
|
||||
t.Fatalf("round resolved %d battles, want 2", len(v.Battles))
|
||||
}
|
||||
fought := map[int]bool{}
|
||||
for _, b := range v.Battles {
|
||||
for _, seat := range b.Seats {
|
||||
fought[seat] = true
|
||||
}
|
||||
}
|
||||
if len(fought) != 4 {
|
||||
t.Errorf("%d of 4 seats fought this round", len(fought))
|
||||
}
|
||||
if v.Battle == nil || !v.Battle.Has(v.YouSeat) {
|
||||
t.Error("the view should single out the battle the human fought")
|
||||
}
|
||||
t.Logf("four-player round resolved: %d battles, seats %v", len(v.Battles), fought)
|
||||
}
|
||||
|
||||
+38
-13
@@ -2,6 +2,7 @@ package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand/v2"
|
||||
"time"
|
||||
@@ -10,19 +11,23 @@ import (
|
||||
"github.com/greyson/super-auto-pets-board-game/internal/game"
|
||||
)
|
||||
|
||||
// botDifficulty maps the API's difficulty names to a skill level and a
|
||||
// table name for the bot. The levels are calibrated against a competent
|
||||
// player (ai.competentLevel, ~0.60): easy loses ~95% of games to them, medium
|
||||
// is an even match, and hard wins ~80% (see TestDiagWinRateCurve). Medium is
|
||||
// pinned to the competent level itself; hard is the strongest bot the engine
|
||||
// can field.
|
||||
// botDifficulty maps the API's difficulty names to a skill level and a pool of
|
||||
// table names. The levels are calibrated against a competent player
|
||||
// (ai.competentLevel, ~0.60): easy loses ~95% of games to them, medium is an
|
||||
// even match, and hard wins ~80% (see TestDiagWinRateCurve). Medium is pinned
|
||||
// to the competent level itself; hard is the strongest bot the engine can
|
||||
// field.
|
||||
//
|
||||
// A table can hold up to five bots, so each difficulty carries a list of names
|
||||
// rather than one: the first unused name is taken, keeping every seat
|
||||
// distinguishable while the name still hints at how hard it plays.
|
||||
var botDifficulty = map[string]struct {
|
||||
level float64
|
||||
name string
|
||||
names []string
|
||||
}{
|
||||
"easy": {0.25, "Robo Rookie"},
|
||||
"medium": {0.60, "Robo Rival"},
|
||||
"hard": {1.00, "Robo Ace"},
|
||||
"easy": {0.25, []string{"Robo Rookie", "Bitsy", "Clunk", "Pip", "Sprocket"}},
|
||||
"medium": {0.60, []string{"Robo Rival", "Gizmo", "Widget", "Rusty", "Cogsworth"}},
|
||||
"hard": {1.00, []string{"Robo Ace", "Vex", "Apex", "Onyx", "Zenith"}},
|
||||
}
|
||||
|
||||
// requireHost authorizes a lobby-management action: only the host (seat 0)
|
||||
@@ -36,17 +41,37 @@ func requireHost(g *game.Game, playerID string) error {
|
||||
}
|
||||
|
||||
// addBot seats a computer opponent for a difficulty name, translating the
|
||||
// name to the engine's skill level. Capacity and phase are enforced by the
|
||||
// engine's AddPlayer.
|
||||
// name to the engine's skill level and giving it a name nobody at the table is
|
||||
// already using. Capacity and phase are enforced by the engine's AddPlayer.
|
||||
func addBot(g *game.Game, difficulty string) error {
|
||||
bot, ok := botDifficulty[difficulty]
|
||||
if !ok {
|
||||
return errors.New("unknown bot difficulty")
|
||||
}
|
||||
_, err := g.AddBot(bot.name, bot.level)
|
||||
_, err := g.AddBot(botName(g, bot.names), bot.level)
|
||||
return err
|
||||
}
|
||||
|
||||
// botName picks the first name in the pool that no seat has taken. If a host
|
||||
// somehow exhausts the pool, it falls back to numbering the first name.
|
||||
func botName(g *game.Game, pool []string) string {
|
||||
taken := make(map[string]bool, len(g.Players))
|
||||
for _, p := range g.Players {
|
||||
taken[p.Name] = true
|
||||
}
|
||||
for _, name := range pool {
|
||||
if !taken[name] {
|
||||
return name
|
||||
}
|
||||
}
|
||||
for n := 2; ; n++ {
|
||||
name := fmt.Sprintf("%s %d", pool[0], n)
|
||||
if !taken[name] {
|
||||
return name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// commitLocked is the one path every game mutation goes through: bots update
|
||||
// their memories from the new public state, the game is persisted, every
|
||||
// client gets its view, and the next bot move (if any) is scheduled. Callers
|
||||
|
||||
@@ -51,14 +51,23 @@ func (s *Server) Handler() http.Handler {
|
||||
return mux
|
||||
}
|
||||
|
||||
// handleCatalog returns every card in a pack (?pack=…, default Turtle), for the
|
||||
// debug panel. Unknown packs fall back to the default.
|
||||
// handleCatalog returns every card in the requested packs, for the debug panel
|
||||
// and the event log's card previews. Packs come as a repeated or
|
||||
// comma-separated ?pack= parameter and default to Turtle; unknown ids fall back
|
||||
// to the default pack's cards.
|
||||
func (s *Server) handleCatalog(w http.ResponseWriter, req *http.Request) {
|
||||
pack := req.URL.Query().Get("pack")
|
||||
if pack == "" {
|
||||
pack = game.DefaultPack
|
||||
var packs []string
|
||||
for _, v := range req.URL.Query()["pack"] {
|
||||
for _, id := range strings.Split(v, ",") {
|
||||
if id = strings.TrimSpace(id); id != "" {
|
||||
packs = append(packs, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
writeJSON(w, game.CatalogForPack(pack))
|
||||
if len(packs) == 0 {
|
||||
packs = []string{game.DefaultPack}
|
||||
}
|
||||
writeJSON(w, game.CatalogForPacks(packs))
|
||||
}
|
||||
|
||||
// room is one live game plus its connections.
|
||||
|
||||
@@ -28,7 +28,7 @@ type clientMessage struct {
|
||||
Pick int `json:"pick"` // tradeChoose
|
||||
Order []string `json:"order"` // arrange
|
||||
Name string `json:"name"` // debugAdd
|
||||
Pack string `json:"pack"` // setPack
|
||||
Packs []string `json:"packs"` // setPacks
|
||||
Difficulty string `json:"difficulty"` // addBot
|
||||
Target string `json:"target"` // removePlayer (player ID)
|
||||
Card string `json:"card"` // revealChoose (Cockatoo): pet card id
|
||||
@@ -123,9 +123,9 @@ func (s *Server) apply(r *room, c *client, msg clientMessage) {
|
||||
g := r.game
|
||||
var err error
|
||||
switch msg.Type {
|
||||
case "setPack":
|
||||
case "setPacks":
|
||||
if err = requireHost(g, c.playerID); err == nil {
|
||||
err = g.SetPack(msg.Pack)
|
||||
err = g.SetPacks(msg.Packs)
|
||||
}
|
||||
case "addBot":
|
||||
if err = requireHost(g, c.playerID); err == nil {
|
||||
|
||||
Reference in New Issue
Block a user