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 Opp OppModel `json:"opp"` } // 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"` } // 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) } 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 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. 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 } } 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 { continue } switch { case e.Kind == game.LogBuy: if c, ok := cardByID(m.PrevShopRow, e.Source); ok { m.Opp.Known = append(m.Opp.Known, c) } else if c, ok := templateByName(e.CardName); ok { m.Opp.Known = append(m.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))) case e.Kind == game.LogTrade: for _, id := range e.Cards { m.removeOppCard(id, "") } case e.Kind == game.LogTradePick: m.Opp.Hidden = append(m.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))) } } } // 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) } } } // Reconcile with the public deck size. Skipped during the battle phase, // where the live deck still holds temporaries the model excludes. if v.Phase == game.PhaseShop || v.Phase == game.PhaseCleanup || 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] } } } 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) 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) return } } if len(m.Opp.Hidden) > 0 { m.Opp.Hidden = m.Opp.Hidden[:len(m.Opp.Hidden)-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. The suit is whatever the first printed copy has — callers // only rely on stats and effects. func templateByName(name string) (game.Card, bool) { if name == "" { return game.Card{}, false } for tier := 1; tier <= game.MaxRounds; tier++ { for _, c := range game.TierContents(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, } }