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)
|
||||
|
||||
Reference in New Issue
Block a user