package ai import ( "encoding/json" "fmt" "slices" "github.com/greyson/super-auto-pets-board-game/internal/game" ) // Memory is the bot's private notebook: everything it has legitimately // learned from public information, carried between turns (and, serialized // into the game state, across server restarts). It is the bot's substitute // for a human player's attention — nothing in here is unavailable to a human // watching the same screen. 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 // 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 // it has actually seen there (battle lineups reveal entire decks each round; // shop buys are public); Hidden counts cards it knows exist but has never // seen — trade-in picks, whose tier is public but whose identity is not. type OppModel struct { Seat int `json:"seat"` Known []game.Card `json:"known"` 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. type HiddenCard struct { Tier int `json:"tier"` Name string `json:"name,omitempty"` } // LoadMemory decodes a bot's stored memory; a nil or corrupt blob yields a // fresh one (the model self-heals from the next battle lineup anyway). 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 } // Marshal encodes the memory for storage on the bot's Player. func (m *Memory) Marshal() json.RawMessage { raw, err := json.Marshal(m) if err != nil { return nil } return raw } // Observe updates the memory from the bot's latest view. The server calls // this on every state change, so consecutive observations are one action // apart. It reads three public sources, in order: // // 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 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 } isOpponent := func(seat int) bool { return seat >= 0 && seat != v.YouSeat && v.PlayerView(seat) != nil } for _, e := range v.Log { if e.Seq <= m.LastSeq { continue } m.LastSeq = e.Seq 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 { opp.Known = append(opp.Known, c) } } else if c, ok := templateByName(v.Packs, e.CardName); ok { if c.Food != game.FoodAvocado { opp.Known = append(opp.Known, c) } } case e.Kind == game.LogSell: 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 { opp.remove(id, "") } case e.Kind == game.LogTradePick: 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 { opp.Known = append(opp.Known, memApple(len(opp.Known))) } } } // 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 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 { 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...) } // 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(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(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] } } } func cardByID(cards []game.Card, id string) (game.Card, bool) { if id == "" { return game.Card{}, false } for _, c := range cards { if c.ID == id { return c, true } } return game.Card{}, false } // 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.TierContentsForPacks(packs, tier) { if c.Name == name { return c, true } } } return game.Card{}, false } // memApple mints an apple for the opponent model. The ID is synthetic — it // only needs to not collide with real card IDs. func memApple(n int) game.Card { return game.Card{ ID: fmt.Sprintf("mem-apple-%d", n), Kind: game.KindFood, Name: "Apple", Food: game.FoodApple, Temporary: true, } }