Add support for up to 6 players.

This commit is contained in:
Greyson Parrelli
2026-07-28 07:36:09 -04:00
parent e542118175
commit a4f5f6910d
38 changed files with 2306 additions and 713 deletions
+112 -58
View File
@@ -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
}